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 5f57ab2ce..a997db64f 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 796ae6311..92ee66365 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 2d3a33e2e..437561070 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 000000000..72b710fa3 --- /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 77e01904d..7cabd7d1f 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 2ba146aa9..45ca6deee 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 cb81ce825..f858e3180 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 d6164842c..55d1b4e39 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 168c59b7c..7c326c559 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 065b41608..f6f8a9e21 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 aca93e1c4..1d912bcb8 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 c47a244a8..586932480 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={