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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { Costs } from "./pages/Costs";
|
|||||||
import { Activity } from "./pages/Activity";
|
import { Activity } from "./pages/Activity";
|
||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { MyIssues } from "./pages/MyIssues";
|
import { MyIssues } from "./pages/MyIssues";
|
||||||
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -25,6 +26,7 @@ export function App() {
|
|||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="companies" element={<Companies />} />
|
<Route path="companies" element={<Companies />} />
|
||||||
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="org" element={<Navigate to="/agents" replace />} />
|
<Route path="org" element={<Navigate to="/agents" replace />} />
|
||||||
<Route path="agents" element={<Agents />} />
|
<Route path="agents" element={<Agents />} />
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
AgentTaskSession,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
Approval,
|
Approval,
|
||||||
AgentConfigRevision,
|
AgentConfigRevision,
|
||||||
@@ -61,7 +62,9 @@ export const agentsApi = {
|
|||||||
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
||||||
revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`),
|
revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`),
|
||||||
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
|
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
|
||||||
resetSession: (id: string) => api.post<void>(`/agents/${id}/runtime-state/reset-session`, {}),
|
taskSessions: (id: string) => api.get<AgentTaskSession[]>(`/agents/${id}/task-sessions`),
|
||||||
|
resetSession: (id: string, taskKey?: string | null) =>
|
||||||
|
api.post<void>(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }),
|
||||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
||||||
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
||||||
wakeup: (
|
wakeup: (
|
||||||
|
|||||||
@@ -81,9 +81,11 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{runtimeState?.sessionId && (
|
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && (
|
||||||
<PropertyRow label="Session">
|
<PropertyRow label="Session">
|
||||||
<span className="text-xs font-mono">{runtimeState.sessionId.slice(0, 12)}...</span>
|
<span className="text-xs font-mono">
|
||||||
|
{String(runtimeState.sessionDisplayId ?? runtimeState.sessionId).slice(0, 12)}...
|
||||||
|
</span>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
)}
|
)}
|
||||||
{runtimeState?.lastError && (
|
{runtimeState?.lastError && (
|
||||||
|
|||||||
94
ui/src/components/ApprovalCard.tsx
Normal file
94
ui/src/components/ApprovalCard.tsx
Normal file
@@ -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 <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
|
||||||
|
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||||
|
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-400" />;
|
||||||
|
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
|
||||||
|
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 (
|
||||||
|
<div className="border border-border rounded-lg p-4 space-y-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{label}</span>
|
||||||
|
{requesterAgent && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{statusIcon(approval.status)}
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payload */}
|
||||||
|
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
||||||
|
|
||||||
|
{/* Decision note */}
|
||||||
|
{approval.decisionNote && (
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
||||||
|
Note: {approval.decisionNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||||
|
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-700 hover:bg-green-600 text-white"
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||||
|
View details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronsUpDown, Plus } from "lucide-react";
|
import { ChevronsUpDown, Plus, Settings } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +63,10 @@ export function CompanySwitcher() {
|
|||||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/company/settings")}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Company Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate("/companies")}>
|
<DropdownMenuItem onClick={() => navigate("/companies")}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Manage Companies
|
Manage Companies
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
SquarePen,
|
SquarePen,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Building2,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -73,6 +72,7 @@ export function Sidebar() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<nav className="flex flex-col gap-4 px-3 py-2">
|
<nav className="flex flex-col gap-4 px-3 py-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/inbox"
|
to="/inbox"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
@@ -89,7 +89,6 @@ export function Sidebar() {
|
|||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
|
||||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/approvals"
|
to="/approvals"
|
||||||
@@ -99,7 +98,6 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
<SidebarNavItem to="/companies" label="Companies" icon={Building2} />
|
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const queryKeys = {
|
|||||||
list: (companyId: string) => ["agents", companyId] as const,
|
list: (companyId: string) => ["agents", companyId] as const,
|
||||||
detail: (id: string) => ["agents", "detail", id] as const,
|
detail: (id: string) => ["agents", "detail", id] as const,
|
||||||
runtimeState: (id: string) => ["agents", "runtime-state", 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,
|
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
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<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
||||||
@@ -182,6 +182,12 @@ export function AgentDetail() {
|
|||||||
enabled: !!agentId,
|
enabled: !!agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: taskSessions } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.taskSessions(agentId!),
|
||||||
|
queryFn: () => agentsApi.taskSessions(agentId!),
|
||||||
|
enabled: !!agentId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: heartbeats } = useQuery({
|
const { data: heartbeats } = useQuery({
|
||||||
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
|
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
|
||||||
queryFn: () => heartbeatsApi.list(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 directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
|
||||||
|
|
||||||
const agentAction = useMutation({
|
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"));
|
if (!agentId) return Promise.reject(new Error("No agent ID"));
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "invoke": return agentsApi.invoke(agentId);
|
case "invoke": return agentsApi.invoke(agentId);
|
||||||
case "pause": return agentsApi.pause(agentId);
|
case "pause": return agentsApi.pause(agentId);
|
||||||
case "resume": return agentsApi.resume(agentId);
|
case "resume": return agentsApi.resume(agentId);
|
||||||
case "terminate": return agentsApi.terminate(agentId);
|
case "terminate": return agentsApi.terminate(agentId);
|
||||||
case "resetSession": return agentsApi.resetSession(agentId);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) });
|
||||||
if (selectedCompanyId) {
|
if (selectedCompanyId) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(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({
|
const updatePermissions = useMutation({
|
||||||
mutationFn: (canCreateAgents: boolean) =>
|
mutationFn: (canCreateAgents: boolean) =>
|
||||||
agentsApi.updatePermissions(agentId!, { canCreateAgents }),
|
agentsApi.updatePermissions(agentId!, { canCreateAgents }),
|
||||||
@@ -356,12 +374,12 @@ export function AgentDetail() {
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
agentAction.mutate("resetSession");
|
resetTaskSession.mutate(null);
|
||||||
setMoreOpen(false);
|
setMoreOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
Reset Session
|
Reset Sessions
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||||
@@ -458,11 +476,14 @@ export function AgentDetail() {
|
|||||||
}
|
}
|
||||||
</SummaryRow>
|
</SummaryRow>
|
||||||
<SummaryRow label="Session">
|
<SummaryRow label="Session">
|
||||||
{runtimeState?.sessionId
|
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId)
|
||||||
? <span className="font-mono text-xs">{runtimeState.sessionId.slice(0, 16)}...</span>
|
? <span className="font-mono text-xs">{String(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId).slice(0, 16)}...</span>
|
||||||
: <span className="text-muted-foreground">No session</span>
|
: <span className="text-muted-foreground">No session</span>
|
||||||
}
|
}
|
||||||
</SummaryRow>
|
</SummaryRow>
|
||||||
|
<SummaryRow label="Task sessions">
|
||||||
|
<span>{taskSessions?.length ?? 0}</span>
|
||||||
|
</SummaryRow>
|
||||||
{runtimeState && (
|
{runtimeState && (
|
||||||
<SummaryRow label="Total spend">
|
<SummaryRow label="Total spend">
|
||||||
<span>{formatCents(runtimeState.totalCostCents)}</span>
|
<span>{formatCents(runtimeState.totalCostCents)}</span>
|
||||||
@@ -541,6 +562,13 @@ export function AgentDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TaskSessionsCard
|
||||||
|
sessions={taskSessions ?? []}
|
||||||
|
onResetTask={(taskKey) => resetTaskSession.mutate(taskKey)}
|
||||||
|
onResetAll={() => resetTaskSession.mutate(null)}
|
||||||
|
resetting={resetTaskSession.isPending}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* CONFIGURATION TAB */}
|
{/* 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 (
|
||||||
|
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">Task Sessions</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2.5 text-xs"
|
||||||
|
onClick={onResetAll}
|
||||||
|
disabled={resetting || sessions.length === 0}
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No task-scoped sessions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.slice(0, 20).map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center justify-between border border-border/70 rounded-md px-3 py-2 gap-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-mono text-xs truncate">{session.taskKey}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{session.sessionDisplayId
|
||||||
|
? `session: ${session.sessionDisplayId}`
|
||||||
|
: "session: <none>"}
|
||||||
|
{session.lastError ? ` | error: ${session.lastError}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2.5 text-xs shrink-0"
|
||||||
|
onClick={() => onResetTask(session.taskKey)}
|
||||||
|
disabled={resetting}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Configuration Tab ---- */
|
/* ---- Configuration Tab ---- */
|
||||||
|
|
||||||
function ConfigurationTab({
|
function ConfigurationTab({
|
||||||
|
|||||||
@@ -6,105 +6,13 @@ import { agentsApi } from "../api/agents";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { CheckCircle2, XCircle, Clock, ShieldCheck } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
import { Identity } from "../components/Identity";
|
import { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
|
||||||
import type { Approval, Agent } from "@paperclip/shared";
|
|
||||||
|
|
||||||
type StatusFilter = "pending" | "all";
|
type StatusFilter = "pending" | "all";
|
||||||
|
|
||||||
function statusIcon(status: string) {
|
|
||||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
|
|
||||||
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
|
||||||
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-400" />;
|
|
||||||
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
|
|
||||||
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 (
|
|
||||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm">{label}</span>
|
|
||||||
{requesterAgent && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{statusIcon(approval.status)}
|
|
||||||
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payload */}
|
|
||||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
|
||||||
|
|
||||||
{/* Decision note */}
|
|
||||||
{approval.decisionNote && (
|
|
||||||
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
|
||||||
Note: {approval.decisionNote}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
|
||||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-green-700 hover:bg-green-600 text-white"
|
|
||||||
onClick={onApprove}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={onReject}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-3">
|
|
||||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
|
||||||
View details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Approvals() {
|
export function Approvals() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
@@ -152,9 +60,11 @@ export function Approvals() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filtered = (data ?? []).filter(
|
const filtered = (data ?? [])
|
||||||
(a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested",
|
.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(
|
const pendingCount = (data ?? []).filter(
|
||||||
(a) => a.status === "pending" || a.status === "revision_requested",
|
(a) => a.status === "pending" || a.status === "revision_requested",
|
||||||
|
|||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Companies" }]);
|
setBreadcrumbs([{ label: "Companies" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
@@ -268,40 +260,6 @@ export function Companies() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<div
|
|
||||||
className="mt-4 border-t border-border pt-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
||||||
Advanced Settings
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">Require board approval for new hires</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
New agent hires stay pending until approved by board.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
company.requireBoardApprovalForNewAgents ? "default" : "outline"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
companySettingsMutation.mutate({
|
|
||||||
id: company.id,
|
|
||||||
requireApproval: !company.requireBoardApprovalForNewAgents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={companySettingsMutation.isPending}
|
|
||||||
>
|
|
||||||
{company.requireBoardApprovalForNewAgents ? "On" : "Off"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
{isConfirmingDelete && (
|
{isConfirmingDelete && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
80
ui/src/pages/CompanySettings.tsx
Normal file
80
ui/src/pages/CompanySettings.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
export function CompanySettings() {
|
||||||
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const settingsMutation = useMutation({
|
||||||
|
mutationFn: (requireApproval: boolean) =>
|
||||||
|
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 (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No company selected. Select a company from the switcher above.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Company Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Hiring
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Require board approval for new hires
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
New agent hires stay pending until approved by board.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
selectedCompany.requireBoardApprovalForNewAgents
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
settingsMutation.mutate(
|
||||||
|
!selectedCompany.requireBoardApprovalForNewAgents,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={settingsMutation.isPending}
|
||||||
|
>
|
||||||
|
{selectedCompany.requireBoardApprovalForNewAgents ? "On" : "Off"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,12 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
Shield,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -143,44 +143,17 @@ export function Inbox() {
|
|||||||
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
|
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-border divide-y divide-border">
|
<div className="grid gap-3">
|
||||||
{actionableApprovals.map((approval) => (
|
{actionableApprovals.map((approval) => (
|
||||||
<div key={approval.id} className="p-4 space-y-2">
|
<ApprovalCard
|
||||||
<div className="flex items-center gap-2">
|
key={approval.id}
|
||||||
<Shield className="h-4 w-4 text-yellow-500 shrink-0" />
|
approval={approval}
|
||||||
<span className="text-sm font-medium">
|
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null : null}
|
||||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
onApprove={() => approveMutation.mutate(approval.id)}
|
||||||
</span>
|
onReject={() => rejectMutation.mutate(approval.id)}
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
onOpen={() => navigate(`/approvals/${approval.id}`)}
|
||||||
{timeAgo(approval.createdAt)}
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-green-700 text-green-500 hover:bg-green-900/20"
|
|
||||||
onClick={() => approveMutation.mutate(approval.id)}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => rejectMutation.mutate(approval.id)}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground ml-auto"
|
|
||||||
onClick={() => navigate(`/approvals/${approval.id}`)}
|
|
||||||
>
|
|
||||||
View details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ export function Issues() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateStatus = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
issuesApi.update(id, { status }),
|
issuesApi.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||||
},
|
},
|
||||||
@@ -157,13 +157,17 @@ export function Issues() {
|
|||||||
title={issue.title}
|
title={issue.title}
|
||||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||||
leading={
|
leading={
|
||||||
<>
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||||
<PriorityIcon priority={issue.priority} />
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<PriorityIcon
|
||||||
|
priority={issue.priority}
|
||||||
|
onChange={(p) => updateIssue.mutate({ id: issue.id, data: { priority: p } })}
|
||||||
|
/>
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(s) => updateStatus.mutate({ id: issue.id, status: s })}
|
onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user