diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 36c30ce6..50483349 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,27 +12,13 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { useNexusMode } from "./hooks/useNexusMode"; -import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; -const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard }))); -const Companies = lazy(() => import("./pages/Companies").then(m => ({ default: m.Companies }))); -const Agents = lazy(() => import("./pages/Agents").then(m => ({ default: m.Agents }))); const AgentDetail = lazy(() => import("./pages/AgentDetail").then(m => ({ default: m.AgentDetail }))); const Projects = lazy(() => import("./pages/Projects").then(m => ({ default: m.Projects }))); const ProjectDetail = lazy(() => import("./pages/ProjectDetail").then(m => ({ default: m.ProjectDetail }))); -const Issues = lazy(() => import("./pages/Issues").then(m => ({ default: m.Issues }))); const IssueDetail = lazy(() => import("./pages/IssueDetail").then(m => ({ default: m.IssueDetail }))); -const Routines = lazy(() => import("./pages/Routines").then(m => ({ default: m.Routines }))); -const RoutineDetail = lazy(() => import("./pages/RoutineDetail").then(m => ({ default: m.RoutineDetail }))); const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail").then(m => ({ default: m.ExecutionWorkspaceDetail }))); -const Goals = lazy(() => import("./pages/Goals").then(m => ({ default: m.Goals }))); -const GoalDetail = lazy(() => import("./pages/GoalDetail").then(m => ({ default: m.GoalDetail }))); -const Approvals = lazy(() => import("./pages/Approvals").then(m => ({ default: m.Approvals }))); -const ApprovalDetail = lazy(() => import("./pages/ApprovalDetail").then(m => ({ default: m.ApprovalDetail }))); -const Costs = lazy(() => import("./pages/Costs").then(m => ({ default: m.Costs }))); -const Activity = lazy(() => import("./pages/Activity").then(m => ({ default: m.Activity }))); -const Inbox = lazy(() => import("./pages/Inbox").then(m => ({ default: m.Inbox }))); const CompanySettings = lazy(() => import("./pages/CompanySettings").then(m => ({ default: m.CompanySettings }))); const CompanySkills = lazy(() => import("./pages/CompanySkills").then(m => ({ default: m.CompanySkills }))); const CompanyExport = lazy(() => import("./pages/CompanyExport").then(m => ({ default: m.CompanyExport }))); @@ -43,7 +29,6 @@ const PluginManager = lazy(() => import("./pages/PluginManager").then(m => ({ de const PluginSettings = lazy(() => import("./pages/PluginSettings").then(m => ({ default: m.PluginSettings }))); const PluginPage = lazy(() => import("./pages/PluginPage").then(m => ({ default: m.PluginPage }))); const RunTranscriptUxLab = lazy(() => import("./pages/RunTranscriptUxLab").then(m => ({ default: m.RunTranscriptUxLab }))); -const OrgChart = lazy(() => import("./pages/OrgChart").then(m => ({ default: m.OrgChart }))); const NewAgent = lazy(() => import("./pages/NewAgent").then(m => ({ default: m.NewAgent }))); const AuthPage = lazy(() => import("./pages/Auth").then(m => ({ default: m.AuthPage }))); const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then(m => ({ default: m.BoardClaimPage }))); @@ -52,7 +37,6 @@ const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ( const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage }))); const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant }))); const ContentStudio = lazy(() => import("./pages/ContentStudio").then(m => ({ default: m.ContentStudio }))); -const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage }))); function BootstrapPendingPage({ hasActiveInvite = false, @@ -156,10 +140,8 @@ function CloudAccessGate() { function boardRoutes() { return ( <> - } /> - } /> + } /> } /> - } /> } /> } /> } /> @@ -167,12 +149,12 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Phase 16b: /agents top-level redirects to /projects. Agent detail pages remain. */} + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -189,37 +171,23 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Phase 16b: /issues top-level redirects to /projects. Issue detail pages remain. */} + } /> + } /> + } /> + } /> + } /> + } /> } /> - } /> - } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> } /> } /> } /> } /> - } /> - } /> - } /> + {/* Phase 16b: /convert folded into Studio Convert workshop. */} + } /> + } /> + } /> } /> } /> } /> @@ -228,10 +196,6 @@ function boardRoutes() { ); } -function InboxRootRedirect() { - return ; -} - function LegacySettingsRedirect() { const location = useLocation(); return ; @@ -264,13 +228,13 @@ function OnboardingRoutePage() { const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 - ? "Create another company" - : "Create your first company"; + ? "Create another workspace" + : "Create your first workspace"; const description = matchedCompany - ? "Run onboarding again to add an agent and a starter task for this company." + ? "Run onboarding again to add an agent and a starter task for this workspace." : companies.length > 0 - ? "Run onboarding again to create another company and seed its first agent." - : "Get started by creating a company and your first agent."; + ? "Run onboarding again to create another workspace and seed its first agent." + : "Get started by creating a workspace and your first agent."; return (
@@ -316,9 +280,9 @@ function CompanyRootRedirect() { } // [nexus] Nexus-first landing: in personal_ai / both (default) modes, land - // on the Personal Assistant. Only project_builder mode lands on the board - // dashboard. URL overrides (typing /PREFIX/dashboard) are still honored. - const landingPath = mode === "project_builder" ? "dashboard" : "assistant"; + // on the Personal Assistant. project_builder mode lands on projects. The + // legacy dashboard route is deleted in Phase 16b. + const landingPath = mode === "project_builder" ? "projects" : "assistant"; return ; } @@ -387,15 +351,10 @@ export function App() { } /> } /> - } /> - } /> } /> - } /> - } /> } /> } /> } /> - } /> } /> } /> } /> diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 648e0b91..383937ee 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -23,11 +23,6 @@ import { CircleDot, Bot, Hexagon, - Target, - LayoutDashboard, - Inbox, - DollarSign, - History, SquarePen, Plus, Search, @@ -192,46 +187,18 @@ export function CommandPalette() { - go("/dashboard")}> - - Dashboard - - go("/inbox")}> - - Inbox - - go("/issues")}> - - Issues + go("/assistant")}> + + Assistant go("/projects")}> Projects - go("/goals")}> - - Goals - - go("/agents")}> - - Agents - - go("/assistant")}> - - Assistant - go("/content-studio")}> Content Studio - go("/costs")}> - - Costs - - go("/activity")}> - - Activity - {visibleIssues.length > 0 && ( diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 55175c8a..d67fccf2 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -101,7 +101,7 @@ function SortableCompanyItem({ { e.preventDefault(); onSelect(); @@ -295,7 +295,7 @@ export function CompanyRail() { onSelect={() => { setSelectedCompanyId(company.id); if (isInstanceRoute) { - navigate(`/${company.issuePrefix}/dashboard`); + navigate(`/${company.issuePrefix}/assistant`); } }} /> diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 0cd8c3a3..be6d8839 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -7,27 +7,24 @@ // "/NEX/assistant" — Phase 33 (v1.5) and Phase 40+ (v1.7) introduced // routes without updating this set. const BOARD_ROUTE_ROOTS = new Set([ - "dashboard", - "companies", "company", "skills", - "org", + // Phase 16b: /agents top-level is redirected to /projects, but agent + // detail pages (/agents/new, /agents/:id, ...) still live under this root. "agents", "projects", "execution-workspaces", + // Phase 16b: /issues top-level is redirected to /projects, but issue + // detail pages (/issues/:issueId) still live here. "issues", - "routines", - "goals", - "approvals", - "costs", - "usage", - "activity", - "inbox", "design-guide", // v1.5 Nexus Personal Assistant "assistant", // v1.7 Content Generation "content-studio", + // Phase 16b: /convert is redirected to /content-studio/convert; the root + // still needs to classify as board so the unprefixed redirect chain + // resolves before the Nexus prefix fallback kicks in. "convert", // Dev / internal tools also under the board scope "plugins", diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx deleted file mode 100644 index c575a930..00000000 --- a/ui/src/pages/Activity.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { VOCAB } from "@paperclipai/branding"; -import { useQuery } from "@tanstack/react-query"; -import { activityApi } from "../api/activity"; -import { agentsApi } from "../api/agents"; -import { issuesApi } from "../api/issues"; -import { projectsApi } from "../api/projects"; -import { goalsApi } from "../api/goals"; -import { useCompany } from "../context/CompanyContext"; -import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { queryKeys } from "../lib/queryKeys"; -import { EmptyState } from "../components/EmptyState"; -import { ActivityRow } from "../components/ActivityRow"; -import { PageSkeleton } from "../components/PageSkeleton"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { History } from "lucide-react"; -import type { Agent } from "@paperclipai/shared"; - -export function Activity() { - const { selectedCompanyId } = useCompany(); - const { setBreadcrumbs } = useBreadcrumbs(); - const [filter, setFilter] = useState("all"); - - useEffect(() => { - setBreadcrumbs([{ label: "Activity" }]); - }, [setBreadcrumbs]); - - const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.activity(selectedCompanyId!), - queryFn: () => activityApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const { data: agents } = useQuery({ - queryKey: queryKeys.agents.list(selectedCompanyId!), - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const { data: projects } = useQuery({ - queryKey: queryKeys.projects.list(selectedCompanyId!), - queryFn: () => projectsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const { data: goals } = useQuery({ - queryKey: queryKeys.goals.list(selectedCompanyId!), - queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const agentMap = useMemo(() => { - const map = new Map(); - for (const a of agents ?? []) map.set(a.id, a); - return map; - }, [agents]); - - const entityNameMap = useMemo(() => { - const map = new Map(); - for (const i of issues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id.slice(0, 8)); - for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name); - for (const p of projects ?? []) map.set(`project:${p.id}`, p.name); - for (const g of goals ?? []) map.set(`goal:${g.id}`, g.title); - return map; - }, [issues, agents, projects, goals]); - - const entityTitleMap = useMemo(() => { - const map = new Map(); - for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); - return map; - }, [issues]); - - if (!selectedCompanyId) { - return ; - } - - if (isLoading) { - return ; - } - - const filtered = - data && filter !== "all" - ? data.filter((e) => e.entityType === filter) - : data; - - const entityTypes = data - ? [...new Set(data.map((e) => e.entityType))].sort() - : []; - - return ( -
-
- -
- - {error &&

{error.message}

} - - {filtered && filtered.length === 0 && ( - - )} - - {filtered && filtered.length > 0 && ( -
- {filtered.map((event) => ( - - ))} -
- )} -
- ); -} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx deleted file mode 100644 index cd95d42e..00000000 --- a/ui/src/pages/Agents.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { VOCAB } from "@paperclipai/branding"; -import { Link, useNavigate, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; -import { agentsApi, type OrgNode } from "../api/agents"; -import { heartbeatsApi } from "../api/heartbeats"; -import { useCompany } from "../context/CompanyContext"; -import { useDialog } from "../context/DialogContext"; -import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useSidebar } from "../context/SidebarContext"; -import { queryKeys } from "../lib/queryKeys"; -import { StatusBadge } from "../components/StatusBadge"; -import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; -import { EntityRow } from "../components/EntityRow"; -import { EmptyState } from "../components/EmptyState"; -import { PageSkeleton } from "../components/PageSkeleton"; -import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; -import { PageTabBar } from "../components/PageTabBar"; -import { Tabs } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react"; -import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; - -const adapterLabels: Record = { - claude_local: "Claude", - codex_local: "Codex", - gemini_local: "Gemini", - opencode_local: "OpenCode", - cursor: "Cursor", - hermes_local: "Hermes", - openclaw_gateway: "OpenClaw Gateway", - process: "Process", - http: "HTTP", -}; - -const roleLabels = AGENT_ROLE_LABELS as Record; - -type FilterTab = "all" | "active" | "paused" | "error"; - -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"; - return true; -} - -function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] { - return agents - .filter((a) => matchesFilter(a.status, tab, showTerminated)) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] { - return nodes - .reduce((acc, node) => { - const filteredReports = filterOrgTree(node.reports, tab, showTerminated); - if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) { - acc.push({ ...node, reports: filteredReports }); - } - return acc; - }, []) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -export function Agents() { - const { selectedCompanyId } = useCompany(); - const { openNewAgent } = useDialog(); - const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); - const location = useLocation(); - const { isMobile } = useSidebar(); - const pathSegment = location.pathname.split("/").pop() ?? "all"; - const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; - const [view, setView] = useState<"list" | "org">("org"); - const forceListView = isMobile; - const effectiveView: "list" | "org" = forceListView ? "list" : view; - const [showTerminated, setShowTerminated] = useState(false); - const [filtersOpen, setFiltersOpen] = useState(false); - - const { data: agents, isLoading, error } = useQuery({ - queryKey: queryKeys.agents.list(selectedCompanyId!), - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const { data: orgTree } = useQuery({ - queryKey: queryKeys.org(selectedCompanyId!), - queryFn: () => agentsApi.org(selectedCompanyId!), - enabled: !!selectedCompanyId && effectiveView === "org", - }); - - const { data: runs } = useQuery({ - queryKey: queryKeys.heartbeats(selectedCompanyId!), - queryFn: () => heartbeatsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - refetchInterval: 15_000, - }); - - // Map agentId -> first live run + live run count - const liveRunByAgent = useMemo(() => { - const map = new Map(); - for (const r of runs ?? []) { - if (r.status !== "running" && r.status !== "queued") continue; - const existing = map.get(r.agentId); - if (existing) { - existing.liveCount += 1; - continue; - } - map.set(r.agentId, { runId: r.id, liveCount: 1 }); - } - return map; - }, [runs]); - - const agentMap = useMemo(() => { - const map = new Map(); - for (const a of agents ?? []) map.set(a.id, a); - return map; - }, [agents]); - - useEffect(() => { - setBreadcrumbs([{ label: "Agents" }]); - }, [setBreadcrumbs]); - - if (!selectedCompanyId) { - return ; - } - - if (isLoading) { - return ; - } - - const filtered = filterAgents(agents ?? [], tab, showTerminated); - const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated); - - return ( -
-
- navigate(`/agents/${v}`)}> - navigate(`/agents/${v}`)} - /> - -
- {/* Filters */} -
- - {filtersOpen && ( -
- -
- )} -
- {/* View toggle */} - {!forceListView && ( -
- - -
- )} - -
-
- - {filtered.length > 0 && ( -

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

- )} - - {error &&

{error.message}

} - - {agents && agents.length === 0 && ( - - )} - - {/* List view */} - {effectiveView === "list" && filtered.length > 0 && ( -
- {filtered.map((agent) => { - return ( - - - - } - trailing={ -
- - {liveRunByAgent.has(agent.id) ? ( - - ) : ( - - )} - -
- {liveRunByAgent.has(agent.id) && ( - - )} - - {adapterLabels[agent.adapterType] ?? agent.adapterType} - - - {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} - - - - -
-
- } - /> - ); - })} -
- )} - - {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && ( -

- No agents match the selected filter. -

- )} - - {/* Org chart view */} - {effectiveView === "org" && filteredOrg.length > 0 && ( -
- {filteredOrg.map((node) => ( - - ))} -
- )} - - {effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && ( -

- No agents match the selected filter. -

- )} - - {effectiveView === "org" && orgTree && orgTree.length === 0 && ( -

- No organizational hierarchy defined. -

- )} -
- ); -} - -function OrgTreeNode({ - node, - depth, - agentMap, - liveRunByAgent, -}: { - node: OrgNode; - depth: number; - agentMap: Map; - liveRunByAgent: Map; -}) { - const agent = agentMap.get(node.id); - - const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault; - - return ( -
- - - - -
- {node.name} - - {roleLabels[node.role] ?? node.role} - {agent?.title ? ` - ${agent.title}` : ""} - -
-
- - {liveRunByAgent.has(node.id) ? ( - - ) : ( - - )} - -
- {liveRunByAgent.has(node.id) && ( - - )} - {agent && ( - <> - - {adapterLabels[agent.adapterType] ?? agent.adapterType} - - - {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} - - - )} - - - -
-
- - {node.reports && node.reports.length > 0 && ( -
- {node.reports.map((child) => ( - - ))} -
- )} -
- ); -} - -function LiveRunIndicator({ - agentRef, - runId, - liveCount, -}: { - agentRef: string; - runId: string; - liveCount: number; -}) { - return ( - e.stopPropagation()} - > - - - - - - Live{liveCount > 1 ? ` (${liveCount})` : ""} - - - ); -} diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx deleted file mode 100644 index f818e3d2..00000000 --- a/ui/src/pages/ApprovalDetail.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { VOCAB } from "@paperclipai/branding"; -import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router"; -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 { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; -import { PageSkeleton } from "../components/PageSkeleton"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react"; -import type { ApprovalComment } from "@paperclipai/shared"; -import { MarkdownBody } from "../components/MarkdownBody"; - -export function ApprovalDetail() { - const { approvalId } = useParams<{ approvalId: string }>(); - const { selectedCompanyId, setSelectedCompanyId } = useCompany(); - const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const queryClient = useQueryClient(); - const [commentBody, setCommentBody] = useState(""); - const [error, setError] = useState(null); - const [showRawPayload, setShowRawPayload] = useState(false); - - const { data: approval, isLoading } = useQuery({ - queryKey: queryKeys.approvals.detail(approvalId!), - queryFn: () => approvalsApi.get(approvalId!), - enabled: !!approvalId, - }); - const resolvedCompanyId = approval?.companyId ?? selectedCompanyId; - - 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(resolvedCompanyId ?? ""), - queryFn: () => agentsApi.list(resolvedCompanyId ?? ""), - enabled: !!resolvedCompanyId, - }); - - useEffect(() => { - if (!approval?.companyId || approval.companyId === selectedCompanyId) return; - setSelectedCompanyId(approval.companyId, { source: "route_sync" }); - }, [approval?.companyId, selectedCompanyId, setSelectedCompanyId]); - - const agentNameById = useMemo(() => { - const map = new Map(); - 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 ; - if (!approval) return

Approval not found.

; - - const payload = approval.payload as Record; - const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null; - const isActionable = approval.status === "pending" || approval.status === "revision_requested"; - const isBudgetApproval = approval.type === "budget_override_required"; - 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.identifier ?? primaryLinkedIssue.id}`, - } - : linkedAgentId - ? { - label: "Open hired agent", - to: `/agents/${linkedAgentId}`, - } - : { - label: "Back to approvals", - to: "/approvals", - }; - - return ( -
- {showApprovedBanner && ( -
-
-
-
- - -
-
-

Approval confirmed

-

- Requesting agent was notified to review this approval and linked issues. -

-
-
- -
-
- )} -
-
-
- -
-

{approvalLabel(approval.type, approval.payload as Record | null)}

-

{approval.id}

-
-
- -
-
- {approval.requestedByAgentId && ( -
- Requested by - -
- )} - - - {showRawPayload && ( -
-              {JSON.stringify(payload, null, 2)}
-            
- )} - {approval.decisionNote && ( -

Decision note: {approval.decisionNote}

- )} -
- {error &&

{error}

} - {linkedIssues && linkedIssues.length > 0 && ( -
-

Linked Issues

-
- {linkedIssues.map((issue) => ( - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.title} - - ))} -
-

- Linked issues remain open until the requesting agent follows up and closes them. -

-
- )} -
- {isActionable && !isBudgetApproval && ( - <> - - - - )} - {isBudgetApproval && approval.status === "pending" && ( -

- Resolve this budget stop from the budget controls on /costs. -

- )} - {approval.status === "pending" && ( - - )} - {approval.status === "revision_requested" && ( - - )} - {approval.status === "rejected" && approval.type === "hire_agent" && linkedAgentId && ( - - )} -
-
- -
-

Comments ({comments?.length ?? 0})

-
- {(comments ?? []).map((comment: ApprovalComment) => ( -
-
- {comment.authorAgentId ? ( - - - - ) : ( - - )} - - {new Date(comment.createdAt).toLocaleString()} - -
- {comment.body} -
- ))} -
-