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 5f57ab2c..a997db64 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 796ae631..92ee6636 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 2d3a33e2..43756107 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 00000000..72b710fa --- /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 77e01904..7cabd7d1 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 2ba146aa..45ca6dee 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 cb81ce82..f858e318 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 d6164842..55d1b4e3 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 168c59b7..7c326c55 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 065b4160..f6f8a9e2 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 aca93e1c..1d912bcb 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 c47a244a..58693248 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={