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 5f57ab2ce9..a997db64f9 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 796ae63114..92ee663656 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 2d3a33e2ed..4375610709 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 0000000000..72b710fa3e --- /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 77e01904df..7cabd7d1f8 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 2ba146aa9e..45ca6deeef 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 cb81ce825f..f858e3180f 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 d6164842c9..55d1b4e39e 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 168c59b7c4..7c326c5591 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 065b416081..f6f8a9e215 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 aca93e1c47..1d912bcb8c 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 c47a244a8b..586932480f 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={