UI: approval detail page, agent hiring UX, costs breakdown, sidebar badges, and dashboard improvements
Add ApprovalDetail page with comment thread, revision request/resubmit flow, and ApprovalPayload component for structured payload display. Extend AgentDetail with permissions management, config revision history, and duplicate action. Add agent hire dialog with permission-gated access. Rework Costs page with per-agent breakdown table and period filtering. Add sidebar badge counts for pending approvals and inbox items. Enhance Dashboard with live metrics and sparkline trends. Extend Agents list with pending_approval status and bulk actions. Update IssueDetail with approval linking. Various component improvements to MetricCard, InlineEditor, CommentThread, and StatusBadge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@paperclip/adapter-utils": "workspace:*",
|
||||
"@paperclip/shared": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"tailwind-merge": "^3.4.1"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
import { Costs } from "./pages/Costs";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
@@ -35,6 +36,7 @@ export function App() {
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="approvals" element={<Approvals />} />
|
||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="inbox" element={<Inbox />} />
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared";
|
||||
import type {
|
||||
Agent,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
HeartbeatRun,
|
||||
Approval,
|
||||
AgentConfigRevision,
|
||||
} from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface AgentKey {
|
||||
@@ -21,16 +28,35 @@ export interface OrgNode {
|
||||
reports: OrgNode[];
|
||||
}
|
||||
|
||||
export interface AgentHireResponse {
|
||||
agent: Agent;
|
||||
approval: Approval | null;
|
||||
}
|
||||
|
||||
export const agentsApi = {
|
||||
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
||||
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
|
||||
listConfigurations: (companyId: string) =>
|
||||
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
||||
get: (id: string) => api.get<Agent>(`/agents/${id}`),
|
||||
getConfiguration: (id: string) => api.get<Record<string, unknown>>(`/agents/${id}/configuration`),
|
||||
listConfigRevisions: (id: string) =>
|
||||
api.get<AgentConfigRevision[]>(`/agents/${id}/config-revisions`),
|
||||
getConfigRevision: (id: string, revisionId: string) =>
|
||||
api.get<AgentConfigRevision>(`/agents/${id}/config-revisions/${revisionId}`),
|
||||
rollbackConfigRevision: (id: string, revisionId: string) =>
|
||||
api.post<Agent>(`/agents/${id}/config-revisions/${revisionId}/rollback`, {}),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
||||
hire: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Agent>(`/agents/${id}`, data),
|
||||
updatePermissions: (id: string, data: { canCreateAgents: boolean }) =>
|
||||
api.patch<Agent>(`/agents/${id}/permissions`, data),
|
||||
pause: (id: string) => api.post<Agent>(`/agents/${id}/pause`, {}),
|
||||
resume: (id: string) => api.post<Agent>(`/agents/${id}/resume`, {}),
|
||||
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
|
||||
remove: (id: string) => api.delete<{ ok: true }>(`/agents/${id}`),
|
||||
listKeys: (id: string) => api.get<AgentKey[]>(`/agents/${id}/keys`),
|
||||
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}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Approval } from "@paperclip/shared";
|
||||
import type { Approval, ApprovalComment, Issue } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const approvalsApi = {
|
||||
@@ -8,8 +8,17 @@ export const approvalsApi = {
|
||||
),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Approval>(`/companies/${companyId}/approvals`, data),
|
||||
get: (id: string) => api.get<Approval>(`/approvals/${id}`),
|
||||
approve: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/approve`, { decisionNote }),
|
||||
reject: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/reject`, { decisionNote }),
|
||||
requestRevision: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/request-revision`, { decisionNote }),
|
||||
resubmit: (id: string, payload?: Record<string, unknown>) =>
|
||||
api.post<Approval>(`/approvals/${id}/resubmit`, { payload }),
|
||||
listComments: (id: string) => api.get<ApprovalComment[]>(`/approvals/${id}/comments`),
|
||||
addComment: (id: string, body: string) =>
|
||||
api.post<ApprovalComment>(`/approvals/${id}/comments`, { body }),
|
||||
listIssues: (id: string) => api.get<Issue[]>(`/approvals/${id}/issues`),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,12 @@ export const companiesApi = {
|
||||
api.post<Company>("/companies", data),
|
||||
update: (
|
||||
companyId: string,
|
||||
data: Partial<Pick<Company, "name" | "description" | "status" | "budgetMonthlyCents">>,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Company,
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents"
|
||||
>
|
||||
>,
|
||||
) => api.patch<Company>(`/companies/${companyId}`, data),
|
||||
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
|
||||
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CostSummary } from "@paperclip/shared";
|
||||
import type { CostSummary, CostByAgent } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CostByEntity {
|
||||
@@ -9,10 +9,19 @@ export interface CostByEntity {
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
function dateParams(from?: string, to?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export const costsApi = {
|
||||
summary: (companyId: string) => api.get<CostSummary>(`/companies/${companyId}/costs/summary`),
|
||||
byAgent: (companyId: string) =>
|
||||
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-agent`),
|
||||
byProject: (companyId: string) =>
|
||||
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-project`),
|
||||
summary: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostSummary>(`/companies/${companyId}/costs/summary${dateParams(from, to)}`),
|
||||
byAgent: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
||||
byProject: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||
};
|
||||
|
||||
@@ -9,3 +9,4 @@ export { costsApi } from "./costs";
|
||||
export { activityApi } from "./activity";
|
||||
export { dashboardApi } from "./dashboard";
|
||||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Issue, IssueComment } from "@paperclip/shared";
|
||||
import type { Approval, Issue, IssueComment } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
@@ -17,4 +17,9 @@ export const issuesApi = {
|
||||
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
|
||||
addComment: (id: string, body: string, reopen?: boolean) =>
|
||||
api.post<IssueComment>(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }),
|
||||
listApprovals: (id: string) => api.get<Approval[]>(`/issues/${id}/approvals`),
|
||||
linkApproval: (id: string, approvalId: string) =>
|
||||
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
|
||||
unlinkApproval: (id: string, approvalId: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
|
||||
};
|
||||
|
||||
6
ui/src/api/sidebarBadges.ts
Normal file
6
ui/src/api/sidebarBadges.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { SidebarBadges } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const sidebarBadgesApi = {
|
||||
get: (companyId: string) => api.get<SidebarBadges>(`/companies/${companyId}/sidebar-badges`),
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown } from "lucide-react";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
Field,
|
||||
@@ -122,28 +122,6 @@ function formatArgList(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function formatEnvVars(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => typeof v === "string")
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractPickedDirectoryPath(handle: unknown): string | null {
|
||||
if (typeof handle !== "object" || handle === null) return null;
|
||||
@@ -540,19 +518,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
minRows={3}
|
||||
/>
|
||||
) : (
|
||||
<DraftTextarea
|
||||
value={eff("adapterConfig", "env", formatEnvVars(config.env))}
|
||||
onCommit={(v) => {
|
||||
const parsed = parseEnvVars(v);
|
||||
mark(
|
||||
"adapterConfig",
|
||||
"env",
|
||||
Object.keys(parsed).length > 0 ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
immediate
|
||||
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
|
||||
minRows={3}
|
||||
<EnvVarEditor
|
||||
value={(eff("adapterConfig", "env", config.env ?? {}) as Record<string, string>)}
|
||||
onChange={(env) => mark("adapterConfig", "env", env)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
@@ -727,6 +695,98 @@ function AdapterTypeDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function EnvVarEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, string>;
|
||||
onChange: (env: Record<string, string> | undefined) => void;
|
||||
}) {
|
||||
type Row = { key: string; value: string };
|
||||
|
||||
function toRows(rec: Record<string, string> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") return [{ key: "", value: "" }];
|
||||
const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) }));
|
||||
return [...entries, { key: "", value: "" }];
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(value));
|
||||
const valueRef = useRef(value);
|
||||
|
||||
// Sync when value identity changes (overlay reset after save)
|
||||
useEffect(() => {
|
||||
if (value !== valueRef.current) {
|
||||
valueRef.current = value;
|
||||
setRows(toRows(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit(nextRows: Row[]) {
|
||||
const rec: Record<string, string> = {};
|
||||
for (const row of nextRows) {
|
||||
const k = row.key.trim();
|
||||
if (k) rec[k] = row.value;
|
||||
}
|
||||
onChange(Object.keys(rec).length > 0 ? rec : undefined);
|
||||
}
|
||||
|
||||
function updateRow(i: number, field: "key" | "value", v: string) {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r));
|
||||
if (next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
const next = rows.filter((_, idx) => idx !== i);
|
||||
if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => {
|
||||
const isTrailing = i === rows.length - 1 && !row.key && !row.value;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={cn(inputClass, "flex-[2]")}
|
||||
placeholder="KEY"
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, "key", e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(i, "value", e.target.value)}
|
||||
/>
|
||||
{!isTrailing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => removeRow(i)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[26px] shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
models,
|
||||
value,
|
||||
|
||||
74
ui/src/components/ApprovalPayload.tsx
Normal file
74
ui/src/components/ApprovalPayload.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
};
|
||||
|
||||
export const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
|
||||
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
)}
|
||||
{!plan && (
|
||||
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -72,7 +73,9 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
||||
<div className="text-sm prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{comment.body}</Markdown>
|
||||
</div>
|
||||
{comment.runId && comment.runAgentId && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface InlineEditorProps {
|
||||
@@ -118,7 +119,13 @@ export function InlineEditor({
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value || placeholder}
|
||||
{value && multiline ? (
|
||||
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{value}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, value, label, description }: MetricCardProps) {
|
||||
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted p-2">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted p-2 rounded-md h-fit shrink-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-2">{description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -76,12 +76,13 @@ export function NewAgentDialog() {
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.create(selectedCompanyId!, data),
|
||||
onSuccess: (agent) => {
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
reset();
|
||||
closeNewAgent();
|
||||
navigate(`/agents/${agent.id}`);
|
||||
navigate(`/agents/${result.agent.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export function NewIssueDialog() {
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
|
||||
@@ -15,15 +15,25 @@ import {
|
||||
BookOpen,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CompanySwitcher } from "./CompanySwitcher";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { data: sidebarBadges } = useQuery({
|
||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
function openSearch() {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
@@ -63,7 +73,12 @@ export function Sidebar() {
|
||||
<ScrollArea className="flex-1">
|
||||
<nav className="flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} />
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
/>
|
||||
<SidebarNavItem to="/my-issues" label="My Issues" icon={ListTodo} />
|
||||
</div>
|
||||
|
||||
@@ -76,7 +91,12 @@ export function Sidebar() {
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
||||
<SidebarNavItem to="/approvals" label="Approvals" icon={ShieldCheck} />
|
||||
<SidebarNavItem
|
||||
to="/approvals"
|
||||
label="Approvals"
|
||||
icon={ShieldCheck}
|
||||
badge={sidebarBadges?.approvals}
|
||||
/>
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/companies" label="Companies" icon={Building2} />
|
||||
|
||||
@@ -12,14 +12,17 @@ const statusColors: Record<string, string> = {
|
||||
failed: "bg-red-900/50 text-red-300",
|
||||
succeeded: "bg-green-900/50 text-green-300",
|
||||
error: "bg-red-900/50 text-red-300",
|
||||
pending_approval: "bg-amber-900/50 text-amber-300",
|
||||
backlog: "bg-neutral-800 text-neutral-400",
|
||||
todo: "bg-blue-900/50 text-blue-300",
|
||||
in_progress: "bg-indigo-900/50 text-indigo-300",
|
||||
in_review: "bg-violet-900/50 text-violet-300",
|
||||
blocked: "bg-amber-900/50 text-amber-300",
|
||||
done: "bg-green-900/50 text-green-300",
|
||||
terminated: "bg-red-900/50 text-red-300",
|
||||
cancelled: "bg-neutral-800 text-neutral-500",
|
||||
pending: "bg-yellow-900/50 text-yellow-300",
|
||||
revision_requested: "bg-amber-900/50 text-amber-300",
|
||||
approved: "bg-green-900/50 text-green-300",
|
||||
rejected: "bg-red-900/50 text-red-300",
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ function invalidateActivityQueries(
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -148,3 +149,45 @@
|
||||
[data-slot="dialog-content"] {
|
||||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Dashboard activity row entry motion */
|
||||
@keyframes dashboard-activity-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-14px) scale(0.985);
|
||||
filter: blur(4px);
|
||||
}
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform: translateY(2px) scale(1.002);
|
||||
filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-activity-highlight {
|
||||
0% {
|
||||
box-shadow: inset 2px 0 0 var(--primary);
|
||||
background-color: color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-row-enter {
|
||||
animation:
|
||||
dashboard-activity-enter 520ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
dashboard-activity-highlight 920ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity-row-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
@@ -16,6 +17,7 @@ export const queryKeys = {
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
|
||||
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
||||
},
|
||||
@@ -30,10 +32,15 @@ export const queryKeys = {
|
||||
approvals: {
|
||||
list: (companyId: string, status?: string) =>
|
||||
["approvals", companyId, status] as const,
|
||||
detail: (approvalId: string) => ["approvals", "detail", approvalId] as const,
|
||||
comments: (approvalId: string) => ["approvals", "comments", approvalId] as const,
|
||||
issues: (approvalId: string) => ["approvals", "issues", approvalId] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
costs: (companyId: string) => ["costs", companyId] as const,
|
||||
costs: (companyId: string, from?: string, to?: string) =>
|
||||
["costs", companyId, from, to] as const,
|
||||
heartbeats: (companyId: string, agentId?: string) =>
|
||||
["heartbeats", companyId, agentId] as const,
|
||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||
|
||||
@@ -202,7 +202,7 @@ export function AgentDetail() {
|
||||
|
||||
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate" | "resetSession") => {
|
||||
@@ -228,6 +228,21 @@ export function AgentDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const updatePermissions = useMutation({
|
||||
mutationFn: (canCreateAgents: boolean) =>
|
||||
agentsApi.updatePermissions(agentId!, { canCreateAgents }),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to update permissions");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Agents", href: "/agents" },
|
||||
@@ -266,6 +281,7 @@ export function AgentDetail() {
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -291,7 +307,7 @@ export function AgentDetail() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={agentAction.isPending}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
Invoke
|
||||
@@ -301,7 +317,7 @@ export function AgentDetail() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => agentAction.mutate("resume")}
|
||||
disabled={agentAction.isPending}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
Resume
|
||||
@@ -311,7 +327,7 @@ export function AgentDetail() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => agentAction.mutate("pause")}
|
||||
disabled={agentAction.isPending}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Pause className="h-3.5 w-3.5 mr-1" />
|
||||
Pause
|
||||
@@ -363,6 +379,11 @@ export function AgentDetail() {
|
||||
</div>
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<p className="text-sm text-amber-500">
|
||||
This agent is pending board approval and cannot be invoked yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Floating Save/Cancel — sticky so it's always reachable when scrolled */}
|
||||
<div
|
||||
@@ -478,7 +499,13 @@ export function AgentDetail() {
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${
|
||||
r.status === "active" ? "bg-green-400" : r.status === "error" ? "bg-red-400" : "bg-neutral-400"
|
||||
r.status === "active"
|
||||
? "bg-green-400"
|
||||
: r.status === "pending_approval"
|
||||
? "bg-amber-400"
|
||||
: r.status === "error"
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400"
|
||||
}`} />
|
||||
</span>
|
||||
{r.name}
|
||||
@@ -494,6 +521,23 @@ export function AgentDetail() {
|
||||
<p className="text-sm mt-0.5">{agent.capabilities}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 border-t border-border/60">
|
||||
<span className="text-xs text-muted-foreground">Permissions</span>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span>Can create new agents</span>
|
||||
<Button
|
||||
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() =>
|
||||
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
|
||||
}
|
||||
disabled={updatePermissions.isPending}
|
||||
>
|
||||
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -585,6 +629,20 @@ function ConfigurationTab({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: configRevisions } = useQuery({
|
||||
queryKey: queryKeys.agents.configRevisions(agent.id),
|
||||
queryFn: () => agentsApi.listConfigRevisions(agent.id),
|
||||
});
|
||||
|
||||
const rollbackConfig = useMutation({
|
||||
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -593,18 +651,58 @@ function ConfigurationTab({
|
||||
}, [onSavingChange, updateAgent.isPending]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl border border-border rounded-lg overflow-hidden">
|
||||
<AgentConfigForm
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
onSave={(patch) => updateAgent.mutate(patch)}
|
||||
isSaving={updateAgent.isPending}
|
||||
adapterModels={adapterModels}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveActionChange={onSaveActionChange}
|
||||
onCancelActionChange={onCancelActionChange}
|
||||
hideInlineSave
|
||||
/>
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<AgentConfigForm
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
onSave={(patch) => updateAgent.mutate(patch)}
|
||||
isSaving={updateAgent.isPending}
|
||||
adapterModels={adapterModels}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveActionChange={onSaveActionChange}
|
||||
onCancelActionChange={onCancelActionChange}
|
||||
hideInlineSave
|
||||
/>
|
||||
</div>
|
||||
<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">Configuration Revisions</h3>
|
||||
<span className="text-xs text-muted-foreground">{configRevisions?.length ?? 0}</span>
|
||||
</div>
|
||||
{(configRevisions ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No configuration revisions yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(configRevisions ?? []).slice(0, 10).map((revision) => (
|
||||
<div key={revision.id} className="border border-border/70 rounded-md p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-mono">{revision.id.slice(0, 8)}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>{formatDate(revision.createdAt)}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>{revision.source}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => rollbackConfig.mutate(revision.id)}
|
||||
disabled={rollbackConfig.isPending}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changed:{" "}
|
||||
{revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { EmptyState } from "../components/EmptyState";
|
||||
import { formatCents, relativeTime, cn } from "../lib/utils";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
@@ -30,23 +30,23 @@ const roleLabels: Record<string, string> = {
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
|
||||
function matchesFilter(status: string, tab: FilterTab): boolean {
|
||||
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
|
||||
if (status === "terminated") return showTerminated;
|
||||
if (tab === "all") return true;
|
||||
if (tab === "active") return status === "active" || status === "running" || status === "idle";
|
||||
if (tab === "paused") return status === "paused";
|
||||
if (tab === "error") return status === "error" || status === "terminated";
|
||||
if (tab === "error") return status === "error";
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||
return agents.filter((a) => matchesFilter(a.status, tab));
|
||||
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
||||
return agents.filter((a) => matchesFilter(a.status, tab, showTerminated));
|
||||
}
|
||||
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
|
||||
if (tab === "all") return nodes;
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
||||
return nodes.reduce<OrgNode[]>((acc, node) => {
|
||||
const filteredReports = filterOrgTree(node.reports, tab);
|
||||
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
|
||||
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
||||
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
||||
acc.push({ ...node, reports: filteredReports });
|
||||
}
|
||||
return acc;
|
||||
@@ -60,6 +60,8 @@ export function Agents() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<FilterTab>("all");
|
||||
const [view, setView] = useState<"list" | "org">("org");
|
||||
const [showTerminated, setShowTerminated] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const { data: agents, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -87,21 +89,51 @@ export function Agents() {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
||||
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as FilterTab)}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="all">All{agents ? ` (${agents.length})` : ""}</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="paused">Paused</TabsTrigger>
|
||||
<TabsTrigger value="error">Error</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filters */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
|
||||
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Filters
|
||||
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
|
||||
</button>
|
||||
{filtersOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setShowTerminated(!showTerminated)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
|
||||
showTerminated && "bg-foreground"
|
||||
)}>
|
||||
{showTerminated && <span className="text-background text-[10px] leading-none">✓</span>}
|
||||
</span>
|
||||
Show terminated
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center border border-border">
|
||||
<button
|
||||
@@ -130,6 +162,10 @@ export function Agents() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
@@ -165,8 +201,10 @@ export function Agents() {
|
||||
? "bg-cyan-400 animate-pulse"
|
||||
: agent.status === "active"
|
||||
? "bg-green-400"
|
||||
: agent.status === "paused"
|
||||
: agent.status === "paused"
|
||||
? "bg-yellow-400"
|
||||
: agent.status === "pending_approval"
|
||||
? "bg-amber-400"
|
||||
: agent.status === "error"
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400"
|
||||
@@ -260,6 +298,8 @@ function OrgTreeNode({
|
||||
? "bg-green-400"
|
||||
: node.status === "paused"
|
||||
? "bg-yellow-400"
|
||||
: node.status === "pending_approval"
|
||||
? "bg-amber-400"
|
||||
: node.status === "error"
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400";
|
||||
|
||||
352
ui/src/pages/ApprovalDetail.tsx
Normal file
352
ui/src/pages/ApprovalDetail.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
||||
import type { ApprovalComment } from "@paperclip/shared";
|
||||
|
||||
export function ApprovalDetail() {
|
||||
const { approvalId } = useParams<{ approvalId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRawPayload, setShowRawPayload] = useState(false);
|
||||
|
||||
const { data: approval, isLoading } = useQuery({
|
||||
queryKey: queryKeys.approvals.detail(approvalId!),
|
||||
queryFn: () => approvalsApi.get(approvalId!),
|
||||
enabled: !!approvalId,
|
||||
});
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.approvals.comments(approvalId!),
|
||||
queryFn: () => approvalsApi.listComments(approvalId!),
|
||||
enabled: !!approvalId,
|
||||
});
|
||||
|
||||
const { data: linkedIssues } = useQuery({
|
||||
queryKey: queryKeys.approvals.issues(approvalId!),
|
||||
queryFn: () => approvalsApi.listIssues(approvalId!),
|
||||
enabled: !!approvalId,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
||||
queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
||||
enabled: !!(approval?.companyId ?? selectedCompanyId),
|
||||
});
|
||||
|
||||
const agentNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const agent of agents ?? []) map.set(agent.id, agent.name);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Approvals", href: "/approvals" },
|
||||
{ label: approval?.id?.slice(0, 8) ?? approvalId ?? "Approval" },
|
||||
]);
|
||||
}, [setBreadcrumbs, approval, approvalId]);
|
||||
|
||||
const refresh = () => {
|
||||
if (!approvalId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(approvalId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.comments(approvalId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.issues(approvalId) });
|
||||
if (approval?.companyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(approval.companyId) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.approvals.list(approval.companyId, "pending"),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(approval.companyId) });
|
||||
}
|
||||
};
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => approvalsApi.approve(approvalId!),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
refresh();
|
||||
navigate(`/approvals/${approvalId}?resolved=approved`, { replace: true });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Approve failed"),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: () => approvalsApi.reject(approvalId!),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Reject failed"),
|
||||
});
|
||||
|
||||
const revisionMutation = useMutation({
|
||||
mutationFn: () => approvalsApi.requestRevision(approvalId!),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Revision request failed"),
|
||||
});
|
||||
|
||||
const resubmitMutation = useMutation({
|
||||
mutationFn: () => approvalsApi.resubmit(approvalId!),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Resubmit failed"),
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: () => approvalsApi.addComment(approvalId!, commentBody.trim()),
|
||||
onSuccess: () => {
|
||||
setCommentBody("");
|
||||
setError(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Comment failed"),
|
||||
});
|
||||
|
||||
const deleteAgentMutation = useMutation({
|
||||
mutationFn: (agentId: string) => agentsApi.remove(agentId),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
refresh();
|
||||
navigate("/approvals");
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (!approval) return <p className="text-sm text-muted-foreground">Approval not found.</p>;
|
||||
|
||||
const payload = approval.payload as Record<string, unknown>;
|
||||
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
|
||||
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
|
||||
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
|
||||
const resolvedCta =
|
||||
primaryLinkedIssue
|
||||
? {
|
||||
label:
|
||||
(linkedIssues?.length ?? 0) > 1
|
||||
? "Review linked issues"
|
||||
: "Review linked issue",
|
||||
to: `/issues/${primaryLinkedIssue.id}`,
|
||||
}
|
||||
: linkedAgentId
|
||||
? {
|
||||
label: "Open hired agent",
|
||||
to: `/agents/${linkedAgentId}`,
|
||||
}
|
||||
: {
|
||||
label: "Back to approvals",
|
||||
to: "/approvals",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{showApprovedBanner && (
|
||||
<div className="border border-green-700/40 bg-green-900/20 rounded-lg px-4 py-3 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative mt-0.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-300" />
|
||||
<Sparkles className="h-3 w-3 text-green-200 absolute -right-2 -top-1 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-green-100 font-medium">Approval confirmed</p>
|
||||
<p className="text-xs text-green-200/90">
|
||||
Requesting agent was notified to review this approval and linked issues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-600/50 text-green-100 hover:bg-green-900/30"
|
||||
onClick={() => navigate(resolvedCta.to)}
|
||||
>
|
||||
{resolvedCta.label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">{approval.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={approval.status} />
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
{approval.requestedByAgentId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Requested by</span>
|
||||
<Identity
|
||||
name={agentNameById.get(approval.requestedByAgentId) ?? approval.requestedByAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ApprovalPayloadRenderer type={approval.type} payload={payload} />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mt-2"
|
||||
onClick={() => setShowRawPayload((v) => !v)}
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${showRawPayload ? "rotate-90" : ""}`} />
|
||||
See full request
|
||||
</button>
|
||||
{showRawPayload && (
|
||||
<pre className="text-xs bg-muted/40 rounded-md p-3 overflow-x-auto">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{approval.decisionNote && (
|
||||
<p className="text-xs text-muted-foreground">Decision note: {approval.decisionNote}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{linkedIssues && linkedIssues.length > 0 && (
|
||||
<div className="pt-2 border-t border-border/60">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Linked Issues</p>
|
||||
<div className="space-y-1.5">
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.id}`}
|
||||
className="block text-xs rounded border border-border/70 px-2 py-1.5 hover:bg-accent/20"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-2">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span>{issue.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-2">
|
||||
Linked issues remain open until the requesting agent follows up and closes them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isActionable && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-700 hover:bg-green-600 text-white"
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={approveMutation.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{approval.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => revisionMutation.mutate()}
|
||||
disabled={revisionMutation.isPending}
|
||||
>
|
||||
Request revision
|
||||
</Button>
|
||||
)}
|
||||
{approval.status === "revision_requested" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resubmitMutation.mutate()}
|
||||
disabled={resubmitMutation.isPending}
|
||||
>
|
||||
Mark resubmitted
|
||||
</Button>
|
||||
)}
|
||||
{approval.status === "rejected" && approval.type === "hire_agent" && linkedAgentId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive border-destructive/40"
|
||||
onClick={() => {
|
||||
if (!window.confirm("Delete this disapproved agent? This cannot be undone.")) return;
|
||||
deleteAgentMutation.mutate(linkedAgentId);
|
||||
}}
|
||||
disabled={deleteAgentMutation.isPending}
|
||||
>
|
||||
Delete disapproved agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium">Comments ({comments?.length ?? 0})</h3>
|
||||
<div className="space-y-2">
|
||||
{(comments ?? []).map((comment: ApprovalComment) => (
|
||||
<div key={comment.id} className="border border-border/60 rounded-md p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Identity
|
||||
name={
|
||||
comment.authorAgentId
|
||||
? agentNameById.get(comment.authorAgentId) ?? comment.authorAgentId.slice(0, 8)
|
||||
: "Board"
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addCommentMutation.mutate()}
|
||||
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
||||
>
|
||||
{addCommentMutation.isPending ? "Posting..." : "Post comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -9,99 +10,37 @@ 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 { ShieldCheck, UserPlus, Lightbulb, CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
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";
|
||||
|
||||
type StatusFilter = "pending" | "all";
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
};
|
||||
|
||||
const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
};
|
||||
|
||||
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 PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
)}
|
||||
{!plan && (
|
||||
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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] ?? ShieldCheck;
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
|
||||
return (
|
||||
@@ -127,11 +66,7 @@ function ApprovalCard({
|
||||
</div>
|
||||
|
||||
{/* Payload */}
|
||||
{approval.type === "hire_agent" ? (
|
||||
<HireAgentPayload payload={approval.payload} />
|
||||
) : (
|
||||
<CeoStrategyPayload payload={approval.payload} />
|
||||
)}
|
||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
||||
|
||||
{/* Decision note */}
|
||||
{approval.decisionNote && (
|
||||
@@ -141,7 +76,7 @@ function ApprovalCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === "pending" && (
|
||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -161,6 +96,11 @@ function ApprovalCard({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +109,7 @@ export function Approvals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("pending");
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
@@ -190,9 +131,10 @@ export function Approvals() {
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_approval, id) => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
@@ -211,10 +153,12 @@ export function Approvals() {
|
||||
});
|
||||
|
||||
const filtered = (data ?? []).filter(
|
||||
(a) => statusFilter === "all" || a.status === "pending",
|
||||
(a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested",
|
||||
);
|
||||
|
||||
const pendingCount = (data ?? []).filter((a) => a.status === "pending").length;
|
||||
const pendingCount = (data ?? []).filter(
|
||||
(a) => a.status === "pending" || a.status === "revision_requested",
|
||||
).length;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
@@ -263,6 +207,7 @@ export function Approvals() {
|
||||
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -68,6 +68,14 @@ 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]);
|
||||
@@ -260,6 +268,40 @@ export function Companies() {
|
||||
</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 */}
|
||||
{isConfirmingDelete && (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { costsApi } from "../api/costs";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -6,24 +6,79 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DollarSign } from "lucide-react";
|
||||
|
||||
type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
||||
|
||||
const PRESET_LABELS: Record<DatePreset, string> = {
|
||||
mtd: "Month to Date",
|
||||
"7d": "Last 7 Days",
|
||||
"30d": "Last 30 Days",
|
||||
ytd: "Year to Date",
|
||||
all: "All Time",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
function computeRange(preset: DatePreset): { from: string; to: string } {
|
||||
const now = new Date();
|
||||
const to = now.toISOString();
|
||||
switch (preset) {
|
||||
case "mtd": {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "7d": {
|
||||
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "30d": {
|
||||
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "ytd": {
|
||||
const d = new Date(now.getFullYear(), 0, 1);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "all":
|
||||
return { from: "", to: "" };
|
||||
case "custom":
|
||||
return { from: "", to: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function Costs() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const [preset, setPreset] = useState<DatePreset>("mtd");
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Costs" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { from, to } = useMemo(() => {
|
||||
if (preset === "custom") {
|
||||
return {
|
||||
from: customFrom ? new Date(customFrom).toISOString() : "",
|
||||
to: customTo ? new Date(customTo + "T23:59:59.999Z").toISOString() : "",
|
||||
};
|
||||
}
|
||||
return computeRange(preset);
|
||||
}, [preset, customFrom, customTo]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.costs(selectedCompanyId!),
|
||||
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
|
||||
queryFn: async () => {
|
||||
const [summary, byAgent, byProject] = await Promise.all([
|
||||
costsApi.summary(selectedCompanyId!),
|
||||
costsApi.byAgent(selectedCompanyId!),
|
||||
costsApi.byProject(selectedCompanyId!),
|
||||
costsApi.summary(selectedCompanyId!, from || undefined, to || undefined),
|
||||
costsApi.byAgent(selectedCompanyId!, from || undefined, to || undefined),
|
||||
costsApi.byProject(selectedCompanyId!, from || undefined, to || undefined),
|
||||
]);
|
||||
return { summary, byAgent, byProject };
|
||||
},
|
||||
@@ -34,42 +89,77 @@ export function Costs() {
|
||||
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
||||
}
|
||||
|
||||
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Date range selector */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{presetKeys.map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={preset === p ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setPreset(p)}
|
||||
>
|
||||
{PRESET_LABELS[p]}
|
||||
</Button>
|
||||
))}
|
||||
{preset === "custom" && (
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<input
|
||||
type="date"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary card */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Month to Date</p>
|
||||
<p className="text-sm text-muted-foreground">{PRESET_LABELS[preset]}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.summary.monthUtilizationPercent}% utilized
|
||||
{data.summary.utilizationPercent}% utilized
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCents(data.summary.monthSpendCents)}{" "}
|
||||
{formatCents(data.summary.spendCents)}{" "}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
/ {formatCents(data.summary.monthBudgetCents)}
|
||||
/ {formatCents(data.summary.budgetCents)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
data.summary.monthUtilizationPercent > 90
|
||||
data.summary.utilizationPercent > 90
|
||||
? "bg-red-400"
|
||||
: data.summary.monthUtilizationPercent > 70
|
||||
: data.summary.utilizationPercent > 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, data.summary.monthUtilizationPercent)}%` }}
|
||||
style={{ width: `${Math.min(100, data.summary.utilizationPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* By Agent / By Project */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -78,15 +168,23 @@ export function Costs() {
|
||||
<p className="text-sm text-muted-foreground">No cost events yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.byAgent.map((row, idx) => (
|
||||
{data.byAgent.map((row) => (
|
||||
<div
|
||||
key={`${row.agentId ?? "na"}-${idx}`}
|
||||
key={row.agentId}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">
|
||||
{row.agentId ?? "Unattributed"}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Identity
|
||||
name={row.agentName ?? row.agentId}
|
||||
size="sm"
|
||||
/>
|
||||
{row.agentStatus === "terminated" && (
|
||||
<StatusBadge status="terminated" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium shrink-0 ml-2">
|
||||
{formatCents(row.costCents)}
|
||||
</span>
|
||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
@@ -16,7 +16,7 @@ import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, Clock } from "lucide-react";
|
||||
import type { Agent, Issue } from "@paperclip/shared";
|
||||
|
||||
@@ -51,6 +51,30 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"company.updated": "updated company",
|
||||
};
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
return from
|
||||
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
||||
: `changed status to ${humanizeValue(details.status)} on`;
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
return from
|
||||
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
||||
: `changed priority to ${humanizeValue(details.priority)} on`;
|
||||
}
|
||||
}
|
||||
return ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function entityLink(entityType: string, entityId: string): string | null {
|
||||
switch (entityType) {
|
||||
case "issue": return `/issues/${entityId}`;
|
||||
@@ -77,6 +101,10 @@ export function Dashboard() {
|
||||
const { openOnboarding } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
const hydratedActivityRef = useRef(false);
|
||||
const activityAnimationTimersRef = useRef<number[]>([]);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -113,6 +141,62 @@ export function Dashboard() {
|
||||
});
|
||||
|
||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
||||
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const timer of activityAnimationTimersRef.current) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
activityAnimationTimersRef.current = [];
|
||||
seenActivityIdsRef.current = new Set();
|
||||
hydratedActivityRef.current = false;
|
||||
setAnimatedActivityIds(new Set());
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recentActivity.length === 0) return;
|
||||
|
||||
const seen = seenActivityIdsRef.current;
|
||||
const currentIds = recentActivity.map((event) => event.id);
|
||||
|
||||
if (!hydratedActivityRef.current) {
|
||||
for (const id of currentIds) seen.add(id);
|
||||
hydratedActivityRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const newIds = currentIds.filter((id) => !seen.has(id));
|
||||
if (newIds.length === 0) {
|
||||
for (const id of currentIds) seen.add(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimatedActivityIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
for (const id of newIds) seen.add(id);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setAnimatedActivityIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
activityAnimationTimersRef.current = activityAnimationTimersRef.current.filter((t) => t !== timer);
|
||||
}, 980);
|
||||
activityAnimationTimersRef.current.push(timer);
|
||||
}, [recentActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of activityAnimationTimersRef.current) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, Agent>();
|
||||
@@ -165,47 +249,74 @@ export function Dashboard() {
|
||||
icon={Bot}
|
||||
value={data.agents.running}
|
||||
label="Agents Running"
|
||||
description={`${data.agents.paused} paused, ${data.agents.error} errors`}
|
||||
onClick={() => navigate("/agents")}
|
||||
description={
|
||||
<span>
|
||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
|
||||
{", "}
|
||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={CircleDot}
|
||||
value={data.tasks.inProgress}
|
||||
label="Tasks In Progress"
|
||||
description={`${data.tasks.open} open, ${data.tasks.blocked} blocked`}
|
||||
onClick={() => navigate("/issues")}
|
||||
description={
|
||||
<span>
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.open} open</span>
|
||||
{", "}
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.blocked} blocked</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={DollarSign}
|
||||
value={formatCents(data.costs.monthSpendCents)}
|
||||
label="Month Spend"
|
||||
description={`${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`}
|
||||
onClick={() => navigate("/costs")}
|
||||
description={
|
||||
<span className="cursor-pointer" onClick={() => navigate("/costs")}>
|
||||
{data.costs.monthUtilizationPercent}% of {formatCents(data.costs.monthBudgetCents)} budget
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ShieldCheck}
|
||||
value={data.pendingApprovals}
|
||||
label="Pending Approvals"
|
||||
description={`${data.staleTasks} stale tasks`}
|
||||
onClick={() => navigate("/approvals")}
|
||||
description={
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>
|
||||
{data.staleTasks} stale tasks
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Recent Activity */}
|
||||
{activity && activity.length > 0 && (
|
||||
{recentActivity.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{activity.slice(0, 10).map((event) => {
|
||||
const verb = ACTION_VERBS[event.action] ?? event.action.replace(/[._]/g, " ");
|
||||
{recentActivity.map((event) => {
|
||||
const verb = formatVerb(event.action, event.details);
|
||||
const name = entityNameMap.get(`${event.entityType}:${event.entityId}`);
|
||||
const link = entityLink(event.entityType, event.entityId);
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
const isAnimated = animatedActivityIds.has(event.id);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`px-4 py-2 flex items-center justify-between gap-2 text-sm ${
|
||||
link ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"px-4 py-2 flex items-center justify-between gap-2 text-sm",
|
||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
isAnimated && "activity-row-enter",
|
||||
)}
|
||||
onClick={link ? () => navigate(link) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
|
||||
@@ -58,8 +58,8 @@ export function Inbox() {
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: approvals, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending"),
|
||||
queryFn: () => approvalsApi.list(selectedCompanyId!, "pending"),
|
||||
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
||||
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
@@ -85,8 +85,9 @@ export function Inbox() {
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") });
|
||||
onSuccess: (_approval, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
@@ -96,7 +97,7 @@ export function Inbox() {
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.reject(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
||||
@@ -107,13 +108,16 @@ export function Inbox() {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
|
||||
const hasApprovals = approvals && approvals.length > 0;
|
||||
const actionableApprovals = (approvals ?? []).filter(
|
||||
(approval) => approval.status === "pending" || approval.status === "revision_requested",
|
||||
);
|
||||
const hasActionableApprovals = actionableApprovals.length > 0;
|
||||
const hasAlerts =
|
||||
dashboard &&
|
||||
(dashboard.agents.error > 0 ||
|
||||
dashboard.costs.monthUtilizationPercent >= 80);
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasContent = hasApprovals || hasAlerts || hasStale;
|
||||
const hasContent = hasActionableApprovals || hasAlerts || hasStale;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -126,7 +130,7 @@ export function Inbox() {
|
||||
)}
|
||||
|
||||
{/* Pending Approvals */}
|
||||
{hasApprovals && (
|
||||
{hasActionableApprovals && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
@@ -140,7 +144,7 @@ export function Inbox() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{approvals!.map((approval) => (
|
||||
{actionableApprovals.map((approval) => (
|
||||
<div key={approval.id} className="p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||
@@ -185,7 +189,7 @@ export function Inbox() {
|
||||
{/* Alerts */}
|
||||
{hasAlerts && (
|
||||
<>
|
||||
{hasApprovals && <Separator />}
|
||||
{hasActionableApprovals && <Separator />}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Alerts
|
||||
@@ -226,7 +230,7 @@ export function Inbox() {
|
||||
{/* Stale Work */}
|
||||
{hasStale && (
|
||||
<>
|
||||
{(hasApprovals || hasAlerts) && <Separator />}
|
||||
{(hasActionableApprovals || hasAlerts) && <Separator />}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Stale Work
|
||||
|
||||
@@ -41,7 +41,40 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"approval.rejected": "rejected",
|
||||
};
|
||||
|
||||
function formatAction(action: string): string {
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
parts.push(
|
||||
from
|
||||
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
||||
: `changed the status to ${humanizeValue(details.status)}`
|
||||
);
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
parts.push(
|
||||
from
|
||||
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
||||
: `changed the priority to ${humanizeValue(details.priority)}`
|
||||
);
|
||||
}
|
||||
if (details.assigneeAgentId !== undefined) {
|
||||
parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue");
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
if (details.description !== undefined) parts.push("updated the description");
|
||||
|
||||
if (parts.length > 0) return parts.join(", ");
|
||||
}
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
@@ -87,6 +120,12 @@ export function IssueDetail() {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: linkedApprovals } = useQuery({
|
||||
queryKey: queryKeys.issues.approvals(issueId!),
|
||||
queryFn: () => issuesApi.listApprovals(issueId!),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
@@ -125,6 +164,7 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
@@ -234,6 +274,33 @@ export function IssueDetail() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Linked Approvals</h3>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{linkedApprovals.map((approval) => (
|
||||
<Link
|
||||
key={approval.id}
|
||||
to={`/approvals/${approval.id}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={approval.status} />
|
||||
<span className="font-medium">
|
||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Linked Runs */}
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<>
|
||||
@@ -248,6 +315,7 @@ export function IssueDetail() {
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Identity name={agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8)} size="sm" />
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="font-mono text-muted-foreground">{run.runId.slice(0, 8)}</span>
|
||||
</div>
|
||||
@@ -269,7 +337,7 @@ export function IssueDetail() {
|
||||
{activity.slice(0, 20).map((evt) => (
|
||||
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} />
|
||||
<span>{formatAction(evt.action)}</span>
|
||||
<span>{formatAction(evt.action, evt.details)}</span>
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -69,6 +69,8 @@ function OrgTreeNode({
|
||||
? "bg-green-400"
|
||||
: node.status === "paused"
|
||||
? "bg-yellow-400"
|
||||
: node.status === "pending_approval"
|
||||
? "bg-amber-400"
|
||||
: node.status === "error"
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400"
|
||||
|
||||
Reference in New Issue
Block a user