From ea60e4800f2e7b3913b1573d2b9c795dc99d4cf6 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 19 Feb 2026 14:02:29 -0600 Subject: [PATCH] UI: task sessions in agent detail, ApprovalCard extraction, and company settings page Show task sessions list in AgentDetail with per-session reset. Extract ApprovalCard into standalone component from Approvals and Inbox pages, reducing duplication. Add CompanySettings page with issuePrefix configuration. Fix Sidebar active state for settings route. Display sessionDisplayId in agent properties. Various cleanups to Approvals and Inbox pages. Co-Authored-By: Claude Opus 4.6 --- ui/src/App.tsx | 2 + ui/src/api/agents.ts | 5 +- ui/src/components/AgentProperties.tsx | 6 +- ui/src/components/ApprovalCard.tsx | 94 +++++++++++++++++++++++ ui/src/components/CompanySwitcher.tsx | 6 +- ui/src/components/Sidebar.tsx | 4 +- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 102 +++++++++++++++++++++++-- ui/src/pages/Approvals.tsx | 104 ++------------------------ ui/src/pages/Companies.tsx | 42 ----------- ui/src/pages/CompanySettings.tsx | 80 ++++++++++++++++++++ ui/src/pages/Inbox.tsx | 49 +++--------- ui/src/pages/Issues.tsx | 18 +++-- 13 files changed, 315 insertions(+), 198 deletions(-) create mode 100644 ui/src/components/ApprovalCard.tsx create mode 100644 ui/src/pages/CompanySettings.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5f57ab2..a997db6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,6 +16,7 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { MyIssues } from "./pages/MyIssues"; +import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; export function App() { @@ -25,6 +26,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 796ae63..92ee663 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -2,6 +2,7 @@ import type { Agent, AgentKeyCreated, AgentRuntimeState, + AgentTaskSession, HeartbeatRun, Approval, AgentConfigRevision, @@ -61,7 +62,9 @@ export const agentsApi = { createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`), runtimeState: (id: string) => api.get(`/agents/${id}/runtime-state`), - resetSession: (id: string) => api.post(`/agents/${id}/runtime-state/reset-session`, {}), + taskSessions: (id: string) => api.get(`/agents/${id}/task-sessions`), + resetSession: (id: string, taskKey?: string | null) => + api.post(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }), adapterModels: (type: string) => api.get(`/adapters/${type}/models`), invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), wakeup: ( diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 2d3a33e..4375610 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -81,9 +81,11 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
- {runtimeState?.sessionId && ( + {(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && ( - {runtimeState.sessionId.slice(0, 12)}... + + {String(runtimeState.sessionDisplayId ?? runtimeState.sessionId).slice(0, 12)}... + )} {runtimeState?.lastError && ( diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx new file mode 100644 index 0000000..72b710f --- /dev/null +++ b/ui/src/components/ApprovalCard.tsx @@ -0,0 +1,94 @@ +import { CheckCircle2, XCircle, Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Identity } from "./Identity"; +import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; +import { timeAgo } from "../lib/timeAgo"; +import type { Approval, Agent } from "@paperclip/shared"; + +function statusIcon(status: string) { + if (status === "approved") return ; + if (status === "rejected") return ; + if (status === "revision_requested") return ; + if (status === "pending") return ; + return null; +} + +export function ApprovalCard({ + approval, + requesterAgent, + onApprove, + onReject, + onOpen, + isPending, +}: { + approval: Approval; + requesterAgent: Agent | null; + onApprove: () => void; + onReject: () => void; + onOpen: () => void; + isPending: boolean; +}) { + const Icon = typeIcon[approval.type] ?? defaultTypeIcon; + const label = typeLabel[approval.type] ?? approval.type; + + return ( +
+ {/* Header */} +
+
+ +
+ {label} + {requesterAgent && ( + + requested by + + )} +
+
+
+ {statusIcon(approval.status)} + {approval.status} + · {timeAgo(approval.createdAt)} +
+
+ + {/* Payload */} + + + {/* Decision note */} + {approval.decisionNote && ( +
+ Note: {approval.decisionNote} +
+ )} + + {/* Actions */} + {(approval.status === "pending" || approval.status === "revision_requested") && ( +
+ + +
+ )} +
+ +
+
+ ); +} diff --git a/ui/src/components/CompanySwitcher.tsx b/ui/src/components/CompanySwitcher.tsx index 77e0190..7cabd7d 100644 --- a/ui/src/components/CompanySwitcher.tsx +++ b/ui/src/components/CompanySwitcher.tsx @@ -1,4 +1,4 @@ -import { ChevronsUpDown, Plus } from "lucide-react"; +import { ChevronsUpDown, Plus, Settings } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useCompany } from "../context/CompanyContext"; import { @@ -63,6 +63,10 @@ export function CompanySwitcher() { No companies )} + navigate("/company/settings")}> + + Company Settings + navigate("/companies")}> Manage Companies diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 2ba146a..45ca6de 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -11,7 +11,6 @@ import { SquarePen, ListTodo, ShieldCheck, - Building2, BookOpen, Paperclip, } from "lucide-react"; @@ -73,6 +72,7 @@ export function Sidebar() { diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index cb81ce8..f858e31 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -8,6 +8,7 @@ export const queryKeys = { list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const, + taskSessions: (id: string) => ["agents", "task-sessions", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, }, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index d616484..55d1b4e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -48,7 +48,7 @@ import { ChevronRight, } from "lucide-react"; import { Input } from "@/components/ui/input"; -import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; +import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState, AgentTaskSession } from "@paperclip/shared"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-400" }, @@ -182,6 +182,12 @@ export function AgentDetail() { enabled: !!agentId, }); + const { data: taskSessions } = useQuery({ + queryKey: queryKeys.agents.taskSessions(agentId!), + queryFn: () => agentsApi.taskSessions(agentId!), + enabled: !!agentId, + }); + const { data: heartbeats } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId), queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId), @@ -205,20 +211,20 @@ export function AgentDetail() { const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated"); const agentAction = useMutation({ - mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate" | "resetSession") => { + mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { if (!agentId) return Promise.reject(new Error("No agent ID")); switch (action) { case "invoke": return agentsApi.invoke(agentId); case "pause": return agentsApi.pause(agentId); case "resume": return agentsApi.resume(agentId); case "terminate": return agentsApi.terminate(agentId); - case "resetSession": return agentsApi.resetSession(agentId); } }, onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); } @@ -228,6 +234,18 @@ export function AgentDetail() { }, }); + const resetTaskSession = useMutation({ + mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey), + onSuccess: () => { + setActionError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Failed to reset session"); + }, + }); + const updatePermissions = useMutation({ mutationFn: (canCreateAgents: boolean) => agentsApi.updatePermissions(agentId!, { canCreateAgents }), @@ -356,12 +374,12 @@ export function AgentDetail() {
+ + resetTaskSession.mutate(taskKey)} + onResetAll={() => resetTaskSession.mutate(null)} + resetting={resetTaskSession.isPending} + /> {/* CONFIGURATION TAB */} @@ -603,6 +631,66 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN ); } +function TaskSessionsCard({ + sessions, + onResetTask, + onResetAll, + resetting, +}: { + sessions: AgentTaskSession[]; + onResetTask: (taskKey: string) => void; + onResetAll: () => void; + resetting: boolean; +}) { + return ( +
+
+

Task Sessions

+ +
+ {sessions.length === 0 ? ( +

No task-scoped sessions.

+ ) : ( +
+ {sessions.slice(0, 20).map((session) => ( +
+
+
{session.taskKey}
+
+ {session.sessionDisplayId + ? `session: ${session.sessionDisplayId}` + : "session: "} + {session.lastError ? ` | error: ${session.lastError}` : ""} +
+
+ +
+ ))} +
+ )} +
+ ); +} + /* ---- Configuration Tab ---- */ function ConfigurationTab({ diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 168c59b..7c326c5 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -6,105 +6,13 @@ import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; -import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { CheckCircle2, XCircle, Clock, ShieldCheck } from "lucide-react"; -import { Identity } from "../components/Identity"; -import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; -import type { Approval, Agent } from "@paperclip/shared"; +import { ShieldCheck } from "lucide-react"; +import { ApprovalCard } from "../components/ApprovalCard"; type StatusFilter = "pending" | "all"; -function statusIcon(status: string) { - if (status === "approved") return ; - if (status === "rejected") return ; - if (status === "revision_requested") return ; - if (status === "pending") return ; - return null; -} - -function ApprovalCard({ - approval, - requesterAgent, - onApprove, - onReject, - onOpen, - isPending, -}: { - approval: Approval; - requesterAgent: Agent | null; - onApprove: () => void; - onReject: () => void; - onOpen: () => void; - isPending: boolean; -}) { - const Icon = typeIcon[approval.type] ?? defaultTypeIcon; - const label = typeLabel[approval.type] ?? approval.type; - - return ( -
- {/* Header */} -
-
- -
- {label} - {requesterAgent && ( - - requested by - - )} -
-
-
- {statusIcon(approval.status)} - {approval.status} - · {timeAgo(approval.createdAt)} -
-
- - {/* Payload */} - - - {/* Decision note */} - {approval.decisionNote && ( -
- Note: {approval.decisionNote} -
- )} - - {/* Actions */} - {(approval.status === "pending" || approval.status === "revision_requested") && ( -
- - -
- )} -
- -
-
- ); -} - export function Approvals() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -152,9 +60,11 @@ export function Approvals() { }, }); - const filtered = (data ?? []).filter( - (a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested", - ); + const filtered = (data ?? []) + .filter( + (a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested", + ) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const pendingCount = (data ?? []).filter( (a) => a.status === "pending" || a.status === "revision_requested", diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index 065b416..f6f8a9e 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -68,14 +68,6 @@ export function Companies() { }, }); - const companySettingsMutation = useMutation({ - mutationFn: ({ id, requireApproval }: { id: string; requireApproval: boolean }) => - companiesApi.update(id, { requireBoardApprovalForNewAgents: requireApproval }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); - }, - }); - useEffect(() => { setBreadcrumbs([{ label: "Companies" }]); }, [setBreadcrumbs]); @@ -268,40 +260,6 @@ export function Companies() { - {selected && ( -
e.stopPropagation()} - > -
- Advanced Settings -
-
-
-
Require board approval for new hires
-
- New agent hires stay pending until approved by board. -
-
- -
-
- )} - {/* Delete confirmation */} {isConfirmingDelete && (
+ companiesApi.update(selectedCompanyId!, { + requireBoardApprovalForNewAgents: requireApproval, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + }, + }); + + useEffect(() => { + setBreadcrumbs([ + { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, + { label: "Settings" }, + ]); + }, [setBreadcrumbs, selectedCompany?.name]); + + if (!selectedCompany) { + return ( +
+ No company selected. Select a company from the switcher above. +
+ ); + } + + return ( +
+
+ +

Company Settings

+
+ +
+
+ Hiring +
+
+
+
+ Require board approval for new hires +
+
+ New agent hires stay pending until approved by board. +
+
+ +
+
+
+ ); +} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index aca93e1..1d912bc 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -11,12 +11,12 @@ import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; +import { ApprovalCard } from "../components/ApprovalCard"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Inbox as InboxIcon, - Shield, AlertTriangle, Clock, ExternalLink, @@ -143,44 +143,17 @@ export function Inbox() { See all approvals
-
+
{actionableApprovals.map((approval) => ( -
-
- - - {approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} - - - {timeAgo(approval.createdAt)} - -
-
- - - -
-
+ a.id === approval.requestedByAgentId) ?? null : null} + onApprove={() => approveMutation.mutate(approval.id)} + onReject={() => rejectMutation.mutate(approval.id)} + onOpen={() => navigate(`/approvals/${approval.id}`)} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> ))}
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index c47a244..5869324 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -78,9 +78,9 @@ export function Issues() { enabled: !!selectedCompanyId, }); - const updateStatus = useMutation({ - mutationFn: ({ id, status }: { id: string; status: string }) => - issuesApi.update(id, { status }), + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); }, @@ -157,13 +157,17 @@ export function Issues() { title={issue.title} onClick={() => navigate(`/issues/${issue.id}`)} leading={ - <> - + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + updateIssue.mutate({ id: issue.id, data: { priority: p } })} + /> updateStatus.mutate({ id: issue.id, status: s })} + onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })} /> - +
} trailing={