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") && ( + + + Approve + + + Reject + + + )} + + + View details + + + + ); +} 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() { { - agentAction.mutate("resetSession"); + resetTaskSession.mutate(null); setMoreOpen(false); }} > - Reset Session + Reset Sessions - {runtimeState?.sessionId - ? {runtimeState.sessionId.slice(0, 16)}... + {(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) + ? {String(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId).slice(0, 16)}... : No session } + + {taskSessions?.length ?? 0} + {runtimeState && ( {formatCents(runtimeState.totalCostCents)} @@ -541,6 +562,13 @@ 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 + + Reset all + + + {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}` : ""} + + + onResetTask(session.taskKey)} + disabled={resetting} + > + Reset + + + ))} + + )} + + ); +} + /* ---- 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") && ( - - - Approve - - - Reject - - - )} - - - View details - - - - ); -} - 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. - - - - companySettingsMutation.mutate({ - id: company.id, - requireApproval: !company.requireBoardApprovalForNewAgents, - }) - } - disabled={companySettingsMutation.isPending} - > - {company.requireBoardApprovalForNewAgents ? "On" : "Off"} - - - - )} - {/* 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. + + + + settingsMutation.mutate( + !selectedCompany.requireBoardApprovalForNewAgents, + ) + } + disabled={settingsMutation.isPending} + > + {selectedCompany.requireBoardApprovalForNewAgents ? "On" : "Off"} + + + + + ); +} 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)} - - - - approveMutation.mutate(approval.id)} - > - Approve - - rejectMutation.mutate(approval.id)} - > - Reject - - navigate(`/approvals/${approval.id}`)} - > - View details - - - + 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={
No task-scoped sessions.