refactor(nexus): delete dead top-level routes (phase 16b)

Phase 11 and Phase 15 demoted dashboard, goals, costs, activity,
org, inbox, convert, approvals, and routines to per-project tabs,
settings pages, or folded them into the Studio / Assistant
surfaces. Phase 16b deletes the top-level route definitions and
the now-orphaned page components.

/issues and /agents top-level lists become Navigate redirects to
/projects for one release cycle (issue and agent detail pages
remain). /convert redirects to the Studio Convert workshop.

boardRoutes now lands on /assistant (not /dashboard). BOARD_ROUTE_
ROOTS is pruned to only the roots that still render pages.
CommandPalette and CompanyRail quick-links are updated to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 16:06:54 +00:00
parent 548cfbdc41
commit 4d667caa1b
20 changed files with 38 additions and 7953 deletions

View file

@ -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 (
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route index element={<Navigate to="assistant" replace />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
@ -167,12 +149,12 @@ function boardRoutes() {
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />
<Route path="org" element={<OrgChart />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
<Route path="agents/active" element={<Agents />} />
<Route path="agents/paused" element={<Agents />} />
<Route path="agents/error" element={<Agents />} />
{/* Phase 16b: /agents top-level redirects to /projects. Agent detail pages remain. */}
<Route path="agents" element={<Navigate to="/projects" replace />} />
<Route path="agents/all" element={<Navigate to="/projects" replace />} />
<Route path="agents/active" element={<Navigate to="/projects" replace />} />
<Route path="agents/paused" element={<Navigate to="/projects" replace />} />
<Route path="agents/error" element={<Navigate to="/projects" replace />} />
<Route path="agents/new" element={<NewAgent />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
@ -189,37 +171,23 @@ function boardRoutes() {
<Route path="projects/:projectId/org" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
{/* Phase 16b: /issues top-level redirects to /projects. Issue detail pages remain. */}
<Route path="issues" element={<Navigate to="/projects" replace />} />
<Route path="issues/all" element={<Navigate to="/projects" replace />} />
<Route path="issues/active" element={<Navigate to="/projects" replace />} />
<Route path="issues/backlog" element={<Navigate to="/projects" replace />} />
<Route path="issues/done" element={<Navigate to="/projects" replace />} />
<Route path="issues/recent" element={<Navigate to="/projects" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="assistant" element={<PersonalAssistant />} />
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
<Route path="content-studio" element={<ContentStudio />} />
<Route path="content-studio/:workshopSlug" element={<ContentStudio />} />
<Route path="convert" element={<ConvertPage />} />
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
{/* Phase 16b: /convert folded into Studio Convert workshop. */}
<Route path="convert" element={<Navigate to="content-studio/convert" replace />} />
<Route path="convert/:sourceFormat" element={<Navigate to="content-studio/convert" replace />} />
<Route path="convert/:sourceFormat/:targetFormat" element={<Navigate to="content-studio/convert" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
@ -228,10 +196,6 @@ function boardRoutes() {
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
@ -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 (
<div className="mx-auto max-w-xl py-10">
@ -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 <Navigate to={`/${targetCompany.issuePrefix}/${landingPath}`} replace />;
}
@ -387,15 +351,10 @@ export function App() {
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />

View file

@ -23,11 +23,6 @@ import {
CircleDot,
Bot,
Hexagon,
Target,
LayoutDashboard,
Inbox,
DollarSign,
History,
SquarePen,
Plus,
Search,
@ -192,46 +187,18 @@ export function CommandPalette() {
<CommandSeparator />
<CommandGroup heading="Pages">
<CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => go("/inbox")}>
<Inbox className="mr-2 h-4 w-4" />
Inbox
</CommandItem>
<CommandItem onSelect={() => go("/issues")}>
<CircleDot className="mr-2 h-4 w-4" />
Issues
<CommandItem onSelect={() => go("/assistant")}>
<MessageSquare className="mr-2 h-4 w-4" />
Assistant
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Hexagon className="mr-2 h-4 w-4" />
Projects
</CommandItem>
<CommandItem onSelect={() => go("/goals")}>
<Target className="mr-2 h-4 w-4" />
Goals
</CommandItem>
<CommandItem onSelect={() => go("/agents")}>
<Bot className="mr-2 h-4 w-4" />
Agents
</CommandItem>
<CommandItem onSelect={() => go("/assistant")}>
<MessageSquare className="mr-2 h-4 w-4" />
Assistant
</CommandItem>
<CommandItem onSelect={() => go("/content-studio")}>
<Sparkles className="mr-2 h-4 w-4" />
Content Studio
</CommandItem>
<CommandItem onSelect={() => go("/costs")}>
<DollarSign className="mr-2 h-4 w-4" />
Costs
</CommandItem>
<CommandItem onSelect={() => go("/activity")}>
<History className="mr-2 h-4 w-4" />
Activity
</CommandItem>
</CommandGroup>
{visibleIssues.length > 0 && (

View file

@ -101,7 +101,7 @@ function SortableCompanyItem({
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<a
href={`/${company.issuePrefix}/dashboard`}
href={`/${company.issuePrefix}/assistant`}
onClick={(e) => {
e.preventDefault();
onSelect();
@ -295,7 +295,7 @@ export function CompanyRail() {
onSelect={() => {
setSelectedCompanyId(company.id);
if (isInstanceRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
navigate(`/${company.issuePrefix}/assistant`);
}
}}
/>

View file

@ -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",

View file

@ -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<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const entityNameMap = useMemo(() => {
const map = new Map<string, string>();
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<string, string>();
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
return map;
}, [issues]);
if (!selectedCompanyId) {
return <EmptyState icon={History} message={`Select a ${VOCAB.company.toLowerCase()} to view activity.`} />;
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const filtered =
data && filter !== "all"
? data.filter((e) => e.entityType === filter)
: data;
const entityTypes = data
? [...new Set(data.map((e) => e.entityType))].sort()
: [];
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{entityTypes.map((type) => (
<SelectItem key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{filtered && filtered.length === 0 && (
<EmptyState icon={History} message="No activity yet." />
)}
{filtered && filtered.length > 0 && (
<div className="border border-border divide-y divide-border">
{filtered.map((event) => (
<ActivityRow
key={event.id}
event={event}
agentMap={agentMap}
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
/>
))}
</div>
)}
</div>
);
}

View file

@ -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<string, string> = {
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<string, string>;
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<OrgNode[]>((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<string, { runId: string; liveCount: number }>();
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<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
useEffect(() => {
setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
return <EmptyState icon={Bot} message={`Select a ${VOCAB.company.toLowerCase()} to view agents.`} />;
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const filtered = filterAgents(agents ?? [], tab, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
<PageTabBar
items={[
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "error", label: "Error" },
]}
value={tab}
onValueChange={(v) => navigate(`/agents/${v}`)}
/>
</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">&#10003;</span>}
</span>
Show terminated
</button>
</div>
)}
</div>
{/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border">
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setView("list")}
>
<List className="h-3.5 w-3.5" />
</button>
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setView("org")}
>
<GitBranch className="h-3.5 w-3.5" />
</button>
</div>
)}
<Button size="sm" variant="outline" onClick={openNewAgent}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Agent
</Button>
</div>
</div>
{filtered.length > 0 && (
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
)}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{agents && agents.length === 0 && (
<EmptyState
icon={Bot}
message="Create your first agent to get started."
action="New Agent"
onAction={openNewAgent}
/>
)}
{/* List view */}
{effectiveView === "list" && filtered.length > 0 && (
<div className="border border-border">
{filtered.map((agent) => {
return (
<EntityRow
key={agent.id}
title={agent.name}
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
leading={
<span className="relative flex h-2.5 w-2.5">
<span
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
/>
</span>
}
trailing={
<div className="flex items-center gap-3">
<span className="sm:hidden">
{liveRunByAgent.has(agent.id) ? (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
) : (
<StatusBadge status={agent.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
<span className="w-20 flex justify-end">
<StatusBadge status={agent.status} />
</span>
</div>
</div>
}
/>
);
})}
</div>
)}
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected filter.
</p>
)}
{/* Org chart view */}
{effectiveView === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1">
{filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected filter.
</p>
)}
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No organizational hierarchy defined.
</p>
)}
</div>
);
}
function OrgTreeNode({
node,
depth,
agentMap,
liveRunByAgent,
}: {
node: OrgNode;
depth: number;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
}) {
const agent = agentMap.get(node.id);
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
return (
<div style={{ paddingLeft: depth * 24 }}>
<Link
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit"
>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
{agent?.title ? ` - ${agent.title}` : ""}
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="sm:hidden">
{liveRunByAgent.has(node.id) ? (
<LiveRunIndicator
agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
) : (
<StatusBadge status={node.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(node.id) && (
<LiveRunIndicator
agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
)}
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
</>
)}
<span className="w-20 flex justify-end">
<StatusBadge status={node.status} />
</span>
</div>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4">
{node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
</div>
);
}
function LiveRunIndicator({
agentRef,
runId,
liveCount,
}: {
agentRef: string;
runId: string;
liveCount: number;
}) {
return (
<Link
to={`/agents/${agentRef}/runs/${runId}`}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-primary/10 hover:bg-primary/20 transition-colors no-underline"
onClick={(e) => e.stopPropagation()}
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
<span className="text-[11px] font-medium text-primary">
Live{liveCount > 1 ? ` (${liveCount})` : ""}
</span>
</Link>
);
}

View file

@ -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<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 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<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 <PageSkeleton variant="detail" />;
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 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 (
<div className="space-y-6 max-w-3xl">
{showApprovedBanner && (
<div className="border border-success/30 bg-success/10 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-success" />
<Sparkles className="h-3 w-3 text-success absolute -right-2 -top-1 animate-pulse" />
</div>
<div>
<p className="text-sm text-success font-medium">Approval confirmed</p>
<p className="text-xs text-success">
Requesting agent was notified to review this approval and linked issues.
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="border-success/30 text-success hover:bg-success/10 hover:bg-success/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">{approvalLabel(approval.type, approval.payload as Record<string, unknown> | null)}</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.identifier ?? 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 && !isBudgetApproval && (
<>
<Button
size="sm"
className="bg-success hover:bg-success text-white"
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => rejectMutation.mutate()}
disabled={rejectMutation.isPending}
>
Reject
</Button>
</>
)}
{isBudgetApproval && approval.status === "pending" && (
<p className="text-sm text-muted-foreground">
Resolve this budget stop from the budget controls on <Link to="/costs" className="underline underline-offset-2">/costs</Link>.
</p>
)}
{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">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentNameById.get(comment.authorAgentId) ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name={VOCAB.board} size="sm" />
)}
<span className="text-xs text-muted-foreground">
{new Date(comment.createdAt).toLocaleString()}
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
</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>
);
}

View file

@ -1,133 +0,0 @@
import { useEffect, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useNavigate, useLocation } from "@/lib/router";
import { useQuery, useMutation, 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 { cn } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { ShieldCheck } from "lucide-react";
import { ApprovalCard } from "../components/ApprovalCard";
import { PageSkeleton } from "../components/PageSkeleton";
type StatusFilter = "pending" | "all";
export function Approvals() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const pathSegment = location.pathname.split("/").pop() ?? "pending";
const statusFilter: StatusFilter = pathSegment === "all" ? "all" : "pending";
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Approvals" }]);
}, [setBreadcrumbs]);
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.approvals.list(selectedCompanyId!),
queryFn: () => approvalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const approveMutation = useMutation({
mutationFn: (id: string) => approvalsApi.approve(id),
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");
},
});
const rejectMutation = useMutation({
mutationFn: (id: string) => approvalsApi.reject(id),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reject");
},
});
const filtered = (data ?? [])
.filter(
(a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested",
)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const pendingCount = (data ?? []).filter(
(a) => a.status === "pending" || a.status === "revision_requested",
).length;
if (!selectedCompanyId) {
return <p className="text-sm text-muted-foreground">{`Select a ${VOCAB.company.toLowerCase()} first.`}</p>;
}
if (isLoading) {
return <PageSkeleton variant="approvals" />;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => navigate(`/approvals/${v}`)}>
<PageTabBar items={[
{ value: "pending", label: <>Pending{pendingCount > 0 && (
<span className={cn(
"ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium",
"bg-warning/20 text-warning"
)}>
{pendingCount}
</span>
)}</> },
{ value: "all", label: "All" },
]} />
</Tabs>
</div>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<ShieldCheck className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
{statusFilter === "pending" ? "No pending approvals." : "No approvals yet."}
</p>
</div>
)}
{filtered.length > 0 && (
<div className="grid gap-3">
{filtered.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null : null}
onApprove={() => approveMutation.mutate(approval.id)}
onReject={() => rejectMutation.mutate(approval.id)}
detailLink={`/approvals/${approval.id}`}
isPending={approveMutation.isPending || rejectMutation.isPending}
/>
))}
</div>
)}
</div>
);
}

View file

@ -1,298 +0,0 @@
import { useState, useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { queryKeys } from "../lib/queryKeys";
import { formatCents, relativeTime } from "../lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Pencil,
Check,
X,
Plus,
MoreHorizontal,
Trash2,
Users,
CircleDot,
DollarSign,
Calendar,
} from "lucide-react";
export function Companies() {
const {
companies,
selectedCompanyId,
setSelectedCompanyId,
loading,
error,
} = useCompany();
const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { data: stats } = useQuery({
queryKey: queryKeys.companies.stats,
queryFn: () => companiesApi.stats(),
});
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const editMutation = useMutation({
mutationFn: ({ id, newName }: { id: string; newName: string }) =>
companiesApi.update(id, { name: newName }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
setEditingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => companiesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats });
setConfirmDeleteId(null);
},
});
useEffect(() => {
setBreadcrumbs([{ label: VOCAB.companies }]);
}, [setBreadcrumbs]);
function startEdit(companyId: string, currentName: string) {
setEditingId(companyId);
setEditName(currentName);
}
function saveEdit() {
if (!editingId || !editName.trim()) return;
editMutation.mutate({ id: editingId, newName: editName.trim() });
}
function cancelEdit() {
setEditingId(null);
setEditName("");
}
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => openOnboarding()}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New {VOCAB.company}
</Button>
</div>
<div className="h-6">
{loading && <p className="text-sm text-muted-foreground">{`Loading ${VOCAB.companies.toLowerCase()}...`}</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
</div>
<div className="grid gap-4">
{companies.map((company) => {
const selected = company.id === selectedCompanyId;
const isEditing = editingId === company.id;
const isConfirmingDelete = confirmDeleteId === company.id;
const companyStats = stats?.[company.id];
const agentCount = companyStats?.agentCount ?? 0;
const issueCount = companyStats?.issueCount ?? 0;
const budgetPct =
company.budgetMonthlyCents > 0
? Math.round(
(company.spentMonthlyCents / company.budgetMonthlyCents) * 100,
)
: 0;
return (
<div
key={company.id}
role="button"
tabIndex={0}
onClick={() => setSelectedCompanyId(company.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedCompanyId(company.id);
}
}}
className={`group text-left bg-card border rounded-lg p-5 transition-colors cursor-pointer ${
selected
? "border-primary ring-1 ring-primary"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header row: name + menu */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") cancelEdit();
}}
/>
<Button
variant="ghost"
size="icon-xs"
onClick={saveEdit}
disabled={editMutation.isPending}
>
<Check className="h-3.5 w-3.5 text-success" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={cancelEdit}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{company.name}</h3>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
company.status === "active"
? "bg-success/10 text-success"
: company.status === "paused"
? "bg-warning/10 text-warning"
: "bg-muted text-muted-foreground"
}`}
>
{company.status}
</span>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
startEdit(company.id, company.name);
}}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
)}
{company.description && !isEditing && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{company.description}
</p>
)}
</div>
{/* Three-dot menu */}
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => startEdit(company.id, company.name)}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setConfirmDeleteId(company.id)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete {VOCAB.company}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Stats row */}
<div className="flex items-center gap-3 sm:gap-5 mt-4 text-sm text-muted-foreground flex-wrap">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
<span>
{agentCount} {agentCount === 1 ? "agent" : "agents"}
</span>
</div>
<div className="flex items-center gap-1.5">
<CircleDot className="h-3.5 w-3.5" />
<span>
{issueCount} {issueCount === 1 ? "issue" : "issues"}
</span>
</div>
<div className="flex items-center gap-1.5 tabular-nums">
<DollarSign className="h-3.5 w-3.5" />
<span>
{formatCents(company.spentMonthlyCents)}
{company.budgetMonthlyCents > 0
? <> / {formatCents(company.budgetMonthlyCents)} <span className="text-xs">({budgetPct}%)</span></>
: <span className="text-xs ml-1">Unlimited budget</span>}
</span>
</div>
<div className="flex items-center gap-1.5 ml-auto">
<Calendar className="h-3.5 w-3.5" />
<span>Created {relativeTime(company.createdAt)}</span>
</div>
</div>
{/* Delete confirmation */}
{isConfirmingDelete && (
<div
className="mt-4 flex items-center justify-between bg-destructive/5 border border-destructive/20 rounded-md px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-destructive font-medium">
{`Delete this ${VOCAB.company.toLowerCase()} and all its data? This cannot be undone.`}
</p>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmDeleteId(null)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(company.id)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { ConvertPanel, FORMAT_GROUPS } from "../components/ConvertPanel";
// All valid formats for case-insensitive validation
const ALL_FORMATS = Object.values(FORMAT_GROUPS).flat();
function normalizeFormatParam(param: string | undefined): string | undefined {
if (!param) return undefined;
const lower = param.toLowerCase();
return ALL_FORMATS.includes(lower) ? lower : undefined;
}
export function ConvertPage() {
const { sourceFormat, targetFormat } = useParams<{
sourceFormat?: string;
targetFormat?: string;
}>();
const { selectedCompanyId } = useCompany();
const companyId = selectedCompanyId ?? "";
// Normalize to lowercase; silently ignore invalid format strings
const initialSourceFormat = normalizeFormatParam(sourceFormat);
const initialTargetFormat = normalizeFormatParam(targetFormat);
return (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">Convert File</h1>
<ConvertPanel
companyId={companyId}
initialSourceFormat={initialSourceFormat}
initialTargetFormat={initialTargetFormat}
/>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,388 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { MetricCard } from "../components/MetricCard";
import { EmptyState } from "../components/EmptyState";
import { StatusIcon } from "../components/StatusIcon";
import { ActivityRow } from "../components/ActivityRow";
import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
export function Dashboard() {
const { selectedCompanyId, companies } = useCompany();
const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
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!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
setBreadcrumbs([{ label: "Dashboard" }]);
}, [setBreadcrumbs]);
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.dashboard(selectedCompanyId!),
queryFn: () => dashboardApi.summary(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: activity } = useQuery({
queryKey: queryKeys.activity(selectedCompanyId!),
queryFn: () => activityApi.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: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const recentIssues = issues ? getRecentIssues(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>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const entityNameMap = useMemo(() => {
const map = new Map<string, string>();
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);
return map;
}, [issues, agents, projects]);
const entityTitleMap = useMemo(() => {
const map = new Map<string, string>();
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
return map;
}, [issues]);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
if (!selectedCompanyId) {
if (companies.length === 0) {
return (
<EmptyState
icon={LayoutDashboard}
message={`Welcome to ${VOCAB.appName}. Set up your first ${VOCAB.company.toLowerCase()} and agent to get started.`}
action="Get Started"
onAction={openOnboarding}
/>
);
}
return (
<EmptyState icon={LayoutDashboard} message={`Create or select a ${VOCAB.company.toLowerCase()} to view the dashboard.`} />
);
}
if (isLoading) {
return <PageSkeleton variant="dashboard" />;
}
const hasNoAgents = agents !== undefined && agents.length === 0;
return (
<div className="space-y-6">
{error && <p className="text-sm text-destructive">{error.message}</p>}
{hasNoAgents && (
<div className="flex items-center justify-between gap-3 rounded-md border border-warning/30 bg-warning/10 px-4 py-3">
<div className="flex items-center gap-2.5">
<Bot className="h-4 w-4 text-warning shrink-0" />
<p className="text-sm text-warning">
You have no agents.
</p>
</div>
<button
onClick={() => openOnboarding({ initialStep: 2, companyId: selectedCompanyId! })}
className="text-sm font-medium text-warning hover:text-warning hover:text-warning underline underline-offset-2 shrink-0"
>
Create one here
</button>
</div>
)}
<ActiveAgentsPanel companyId={selectedCompanyId!} />
{data && (
<>
{data.budgets.activeIncidents > 0 ? (
<div className="flex items-start justify-between gap-3 rounded-xl border border-destructive/20 bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))] px-4 py-3">
<div className="flex items-start gap-2.5">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
</p>
<p className="text-xs text-destructive">
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
</p>
</div>
</div>
<Link to="/costs" className="text-sm underline underline-offset-2 text-destructive">
Open budgets
</Link>
</div>
) : null}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
<MetricCard
icon={Bot}
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
label="Agents Enabled"
to="/agents"
description={
<span>
{data.agents.running} running{", "}
{data.agents.paused} paused{", "}
{data.agents.error} errors
</span>
}
/>
<MetricCard
icon={CircleDot}
value={data.tasks.inProgress}
label="Tasks In Progress"
to="/issues"
description={
<span>
{data.tasks.open} open{", "}
{data.tasks.blocked} blocked
</span>
}
/>
<MetricCard
icon={DollarSign}
value={formatCents(data.costs.monthSpendCents)}
label="Month Spend"
to="/costs"
description={
<span>
{data.costs.monthBudgetCents > 0
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
: "Unlimited budget"}
</span>
}
/>
<MetricCard
icon={ShieldCheck}
value={data.pendingApprovals + data.budgets.pendingApprovals}
label="Pending Approvals"
to="/approvals"
description={
<span>
{data.budgets.pendingApprovals > 0
? `${data.budgets.pendingApprovals} budget overrides awaiting owner review`
: "Awaiting owner review"}
</span>
}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs ?? []} />
</ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
<PriorityChart issues={issues ?? []} />
</ChartCard>
<ChartCard title="Issues by Status" subtitle="Last 14 days">
<IssueStatusChart issues={issues ?? []} />
</ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days">
<SuccessRateChart runs={runs ?? []} />
</ChartCard>
</div>
<PluginSlotOutlet
slotTypes={["dashboardWidget"]}
context={{ companyId: selectedCompanyId }}
className="grid gap-4 md:grid-cols-2"
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
/>
<div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */}
{recentActivity.length > 0 && (
<div className="min-w-0">
<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 overflow-hidden">
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
event={event}
agentMap={agentMap}
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
/>
))}
</div>
</div>
)}
{/* Recent Tasks */}
<div className="min-w-0">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Tasks
</h3>
{recentIssues.length === 0 ? (
<div className="border border-border p-4">
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</div>
) : (
<div className="border border-border divide-y divide-border overflow-hidden">
{recentIssues.slice(0, 10).map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}

View file

@ -1,196 +0,0 @@
import { useEffect } from "react";
import { useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { projectsApi } from "../api/projects";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { GoalProperties } from "../components/GoalProperties";
import { GoalTree } from "../components/GoalTree";
import { StatusBadge } from "../components/StatusBadge";
import { InlineEditor } from "../components/InlineEditor";
import { EntityRow } from "../components/EntityRow";
import { PageSkeleton } from "../components/PageSkeleton";
import { projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
import type { Goal, Project } from "@paperclipai/shared";
export function GoalDetail() {
const { goalId } = useParams<{ goalId: string }>();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openNewGoal } = useDialog();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const {
data: goal,
isLoading,
error
} = useQuery({
queryKey: queryKeys.goals.detail(goalId!),
queryFn: () => goalsApi.get(goalId!),
enabled: !!goalId
});
const resolvedCompanyId = goal?.companyId ?? selectedCompanyId;
const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(resolvedCompanyId!),
queryFn: () => goalsApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId
});
const { data: allProjects } = useQuery({
queryKey: queryKeys.projects.list(resolvedCompanyId!),
queryFn: () => projectsApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId
});
useEffect(() => {
if (!goal?.companyId || goal.companyId === selectedCompanyId) return;
setSelectedCompanyId(goal.companyId, { source: "route_sync" });
}, [goal?.companyId, selectedCompanyId, setSelectedCompanyId]);
const updateGoal = useMutation({
mutationFn: (data: Record<string, unknown>) =>
goalsApi.update(goalId!, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.goals.detail(goalId!)
});
if (resolvedCompanyId) {
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(resolvedCompanyId)
});
}
}
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
if (!resolvedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(
resolvedCompanyId,
file,
`goals/${goalId ?? "draft"}`
);
}
});
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
const linkedProjects = (allProjects ?? []).filter((p) => {
if (!goalId) return false;
if (p.goalIds.includes(goalId)) return true;
if (p.goals.some((goalRef) => goalRef.id === goalId)) return true;
return p.goalId === goalId;
});
useEffect(() => {
setBreadcrumbs([
{ label: "Goals", href: "/goals" },
{ label: goal?.title ?? goalId ?? "Goal" }
]);
}, [setBreadcrumbs, goal, goalId]);
useEffect(() => {
if (goal) {
openPanel(
<GoalProperties
goal={goal}
onUpdate={(data) => updateGoal.mutate(data)}
/>
);
}
return () => closePanel();
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!goal) return null;
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs uppercase text-muted-foreground">
{goal.level}
</span>
<StatusBadge status={goal.status} />
</div>
<InlineEditor
value={goal.title}
onSave={(title) => updateGoal.mutate({ title })}
as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={goal.description ?? ""}
onSave={(description) => updateGoal.mutate({ description })}
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
imageUploadHandler={async (file) => {
const asset = await uploadImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>
<Tabs defaultValue="children">
<TabsList>
<TabsTrigger value="children">
Sub-Goals ({childGoals.length})
</TabsTrigger>
<TabsTrigger value="projects">
Projects ({linkedProjects.length})
</TabsTrigger>
</TabsList>
<TabsContent value="children" className="mt-4 space-y-3">
<div className="flex items-center justify-start">
<Button
size="sm"
variant="outline"
onClick={() => openNewGoal({ parentId: goalId })}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Sub Goal
</Button>
</div>
{childGoals.length === 0 ? (
<p className="text-sm text-muted-foreground">No sub-goals.</p>
) : (
<GoalTree goals={childGoals} goalLink={(g) => `/goals/${g.id}`} />
)}
</TabsContent>
<TabsContent value="projects" className="mt-4">
{linkedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">No linked projects.</p>
) : (
<div className="border border-border">
{linkedProjects.map((project) => (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
to={projectUrl(project)}
trailing={<StatusBadge status={project.status} />}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,64 +0,0 @@
import { useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { GoalTree } from "../components/GoalTree";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Target, Plus } from "lucide-react";
export function Goals() {
const { selectedCompanyId } = useCompany();
const { openNewGoal } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Goals" }]);
}, [setBreadcrumbs]);
const { data: goals, isLoading, error } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
if (!selectedCompanyId) {
return <EmptyState icon={Target} message={`Select a ${VOCAB.company.toLowerCase()} to view goals.`} />;
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
return (
<div className="space-y-4">
{error && <p className="text-sm text-destructive">{error.message}</p>}
{goals && goals.length === 0 && (
<EmptyState
icon={Target}
message="No goals yet."
action="Add Goal"
onAction={() => openNewGoal()}
/>
)}
{goals && goals.length > 0 && (
<>
<div className="flex items-center justify-start">
<Button size="sm" variant="outline" onClick={() => openNewGoal()}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Goal
</Button>
</div>
<GoalTree goals={goals} goalLink={(goal) => `/goals/${goal.id}`} />
</>
)}
</div>
);
}

View file

@ -1,242 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
useNavigate: () => () => {},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-904",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 904,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
};
}
describe("FailedRunInboxRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("suppresses accent hover styling when selected", () => {
const root = createRoot(container);
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
invocationSource: "assignment",
triggerDetail: null,
status: "failed",
error: "boom",
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date("2026-03-11T00:00:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
} as const;
act(() => {
root.render(
<FailedRunInboxRow
run={run}
issueById={new Map()}
agentName="Agent"
issueLinkState={null}
onDismiss={() => {}}
onRetry={() => {}}
isRetrying={false}
selected
/>,
);
});
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(link?.className).toContain("hover:bg-transparent");
expect(link?.className).not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
});
describe("InboxIssueMetaLeading", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("keeps status and live accents visible", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
});
const statusIcon = container.querySelector('span[class*="border-blue-600"]');
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
(node) => node.textContent === "Live" && node.className.includes("text-"),
);
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).not.toContain("!border-muted-foreground");
expect(statusIcon?.className).not.toContain("!text-muted-foreground");
expect(liveBadge).not.toBeNull();
expect(liveBadge?.className).toContain("bg-blue-500/10");
expect(liveBadgeLabel).not.toBeNull();
expect(liveBadgeLabel?.className).toContain("text-blue-600");
expect(liveDot).not.toBeNull();
expect(pulseRing).not.toBeNull();
act(() => {
root.unmount();
});
});
});
describe("InboxIssueTrailingColumns", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders an empty tags cell when an issue has no labels", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue({ labels: [], labelIds: [] })}
columns={["labels"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => {
root.unmount();
});
});
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue()}
columns={["workspace"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => {
root.unmount();
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,118 +0,0 @@
import { useEffect, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
export function Issues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const handleSearchChange = useCallback((search: string) => {
const trimmedSearch = search.trim();
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
if (currentSearch === trimmedSearch) return;
const url = new URL(window.location.href);
if (trimmedSearch) {
url.searchParams.set("q", trimmedSearch);
} else {
url.searchParams.delete("q");
}
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(window.history.state, "", nextUrl);
}, []);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Issues",
`${location.pathname}${location.search}${location.hash}`,
"issues",
),
[location.pathname, location.search, location.hash],
);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
const { data: issues, isLoading, error } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
enabled: !!selectedCompanyId,
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
},
});
if (!selectedCompanyId) {
return <EmptyState icon={CircleDot} message={`Select a ${VOCAB.company.toLowerCase()} to view issues.`} />;
}
return (
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
error={error as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:issues-view"
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialSearch={initialSearch}
onSearchChange={handleSearchChange}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId ? { participantAgentId } : undefined}
/>
);
}

View file

@ -1,455 +0,0 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { agentUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Download, Network, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
// Layout constants
const CARD_W = 200;
const CARD_H = 100;
const GAP_X = 32;
const GAP_Y = 80;
const PADDING = 60;
// ── Tree layout types ───────────────────────────────────────────────────
interface LayoutNode {
id: string;
name: string;
role: string;
status: string;
x: number;
y: number;
children: LayoutNode[];
}
// ── Layout algorithm ────────────────────────────────────────────────────
/** Compute the width each subtree needs. */
function subtreeWidth(node: OrgNode): number {
if (node.reports.length === 0) return CARD_W;
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
return Math.max(CARD_W, childrenW + gaps);
}
/** Recursively assign x,y positions. */
function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
const totalW = subtreeWidth(node);
const layoutChildren: LayoutNode[] = [];
if (node.reports.length > 0) {
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
let cx = x + (totalW - childrenW - gaps) / 2;
for (const child of node.reports) {
const cw = subtreeWidth(child);
layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y));
cx += cw + GAP_X;
}
}
return {
id: node.id,
name: node.name,
role: node.role,
status: node.status,
x: x + (totalW - CARD_W) / 2,
y,
children: layoutChildren,
};
}
/** Layout all root nodes side by side. */
function layoutForest(roots: OrgNode[]): LayoutNode[] {
if (roots.length === 0) return [];
const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0);
const gaps = (roots.length - 1) * GAP_X;
let x = PADDING;
const y = PADDING;
const result: LayoutNode[] = [];
for (const root of roots) {
const w = subtreeWidth(root);
result.push(layoutTree(root, x, y));
x += w + GAP_X;
}
// Compute bounds and return
return result;
}
/** Flatten layout tree to list of nodes. */
function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
const result: LayoutNode[] = [];
function walk(n: LayoutNode) {
result.push(n);
n.children.forEach(walk);
}
nodes.forEach(walk);
return result;
}
/** Collect all parent→child edges. */
function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> {
const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = [];
function walk(n: LayoutNode) {
for (const c of n.children) {
edges.push({ parent: n, child: c });
walk(c);
}
}
nodes.forEach(walk);
return edges;
}
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
const adapterLabels: Record<string, string> = {
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",
};
// [nexus] Design system migration Phase 3 — status colors now reference
// semantic CSS variables so they auto-switch with light/dark themes.
const statusDotColor: Record<string, string> = {
running: "var(--primary)",
active: "var(--success)",
paused: "var(--warning)",
idle: "var(--muted-foreground)",
error: "var(--destructive)",
terminated: "var(--muted-foreground)",
};
const defaultDotColor = "var(--muted-foreground)";
// ── Main component ──────────────────────────────────────────────────────
export function OrgChart() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const { data: orgTree, isLoading } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentMap = useMemo(() => {
const m = new Map<string, Agent>();
for (const a of agents ?? []) m.set(a.id, a);
return m;
}, [agents]);
useEffect(() => {
setBreadcrumbs([{ label: "Org Chart" }]);
}, [setBreadcrumbs]);
// Layout computation
const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]);
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
const edges = useMemo(() => collectEdges(layout), [layout]);
// Compute SVG bounds
const bounds = useMemo(() => {
if (allNodes.length === 0) return { width: 800, height: 600 };
let maxX = 0, maxY = 0;
for (const n of allNodes) {
maxX = Math.max(maxX, n.x + CARD_W);
maxY = Math.max(maxY, n.y + CARD_H);
}
return { width: maxX + PADDING, height: maxY + PADDING };
}, [allNodes]);
// Pan & zoom state
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [dragging, setDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
// Center the chart on first load
const hasInitialized = useRef(false);
useEffect(() => {
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
hasInitialized.current = true;
const container = containerRef.current;
const containerW = container.clientWidth;
const containerH = container.clientHeight;
// Fit chart to container
const scaleX = (containerW - 40) / bounds.width;
const scaleY = (containerH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({
x: (containerW - chartW) / 2,
y: (containerH - chartH) / 2,
});
}, [allNodes, bounds]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Don't drag if clicking a card
const target = e.target as HTMLElement;
if (target.closest("[data-org-card]")) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
}, [dragging]);
const handleMouseUp = useCallback(() => {
setDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = Math.min(Math.max(zoom * factor, 0.2), 2);
// Zoom toward mouse position
const scale = newZoom / zoom;
setPan({
x: mouseX - scale * (mouseX - pan.x),
y: mouseY - scale * (mouseY - pan.y),
});
setZoom(newZoom);
}, [zoom, pan]);
if (!selectedCompanyId) {
return <EmptyState icon={Network} message={`Select a ${VOCAB.company.toLowerCase()} to view the org chart.`} />;
}
if (isLoading) {
return <PageSkeleton variant="org-chart" />;
}
if (orgTree && orgTree.length === 0) {
return <EmptyState icon={Network} message="No organizational hierarchy defined." />;
}
return (
<div className="flex flex-col h-full">
<div className="mb-2 flex items-center justify-start gap-2 shrink-0">
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
</div>
<div
ref={containerRef}
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{ cursor: dragging ? "grabbing" : "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
{/* Zoom controls */}
<div className="absolute top-3 right-3 z-10 flex flex-col gap-1">
<button
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-sm hover:bg-accent transition-colors"
onClick={() => {
const newZoom = Math.min(zoom * 1.2, 2);
const container = containerRef.current;
if (container) {
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const scale = newZoom / zoom;
setPan({ x: cx - scale * (cx - pan.x), y: cy - scale * (cy - pan.y) });
}
setZoom(newZoom);
}}
aria-label="Zoom in"
>
+
</button>
<button
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-sm hover:bg-accent transition-colors"
onClick={() => {
const newZoom = Math.max(zoom * 0.8, 0.2);
const container = containerRef.current;
if (container) {
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const scale = newZoom / zoom;
setPan({ x: cx - scale * (cx - pan.x), y: cy - scale * (cy - pan.y) });
}
setZoom(newZoom);
}}
aria-label="Zoom out"
>
&minus;
</button>
<button
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-[10px] hover:bg-accent transition-colors"
onClick={() => {
if (!containerRef.current) return;
const cW = containerRef.current.clientWidth;
const cH = containerRef.current.clientHeight;
const scaleX = (cW - 40) / bounds.width;
const scaleY = (cH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
}}
title="Fit to screen"
aria-label="Fit chart to screen"
>
Fit
</button>
</div>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
style={{
width: "100%",
height: "100%",
}}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{edges.map(({ parent, child }) => {
const x1 = parent.x + CARD_W / 2;
const y1 = parent.y + CARD_H;
const x2 = child.x + CARD_W / 2;
const y2 = child.y;
const midY = (y1 + y2) / 2;
return (
<path
key={`${parent.id}-${child.id}`}
d={`M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`}
fill="none"
stroke="var(--border)"
strokeWidth={1.5}
/>
);
})}
</g>
</svg>
{/* Card layer */}
<div
className="absolute inset-0"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0",
}}
>
{allNodes.map((node) => {
const agent = agentMap.get(node.id);
const dotColor = statusDotColor[node.status] ?? defaultDotColor;
return (
<div
key={node.id}
data-org-card
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-[box-shadow,border-color] duration-150 cursor-pointer select-none"
style={{
left: node.x,
top: node.y,
width: CARD_W,
minHeight: CARD_H,
}}
onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
>
<div className="flex items-center px-4 py-3 gap-3">
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentIcon icon={agent?.icon} className="h-4.5 w-4.5 text-foreground/70" />
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"
style={{ backgroundColor: dotColor }}
/>
</div>
{/* Name + role + adapter type */}
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="text-sm font-semibold text-foreground leading-tight">
{node.name}
</span>
<span className="text-[11px] text-muted-foreground leading-tight mt-0.5">
{agent?.title ?? roleLabel(node.role)}
</span>
{agent && (
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
</span>
)}
{agent && agent.capabilities && (
<span className="text-[10px] text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
{agent.capabilities}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
function roleLabel(role: string): string {
return roleLabels[role] ?? role;
}

File diff suppressed because it is too large Load diff

View file

@ -1,740 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
skip_if_active: "Drop new trigger occurrences while a run is still active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}
function formatLastRunTimestamp(value: Date | string | null | undefined) {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
if (currentStatus === "archived" && enabled) return "active";
return enabled ? "active" : "paused";
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToast();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState<{
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}>({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]);
const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]);
const createRoutine = useMutation({
mutationFn: () =>
routinesApi.create(selectedCompanyId!, {
...draft,
description: draft.description.trim() || null,
}),
onSuccess: async (routine) => {
setDraft({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
setComposerOpen(false);
setAdvancedOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({
title: "Routine created",
body: "Add the first trigger to turn it into a live workflow.",
tone: "success",
});
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
onMutate: ({ id }) => {
setStatusMutationRoutineId(id);
},
onSuccess: async (_, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(variables.id) }),
]);
},
onSettled: () => {
setStatusMutationRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Failed to update routine",
body: mutationError instanceof Error ? mutationError.message : `${VOCAB.appName} could not update the routine.`,
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
: {}),
...(data?.executionWorkspaceSettings !== undefined
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
: {}),
}),
onMutate: ({ id }) => {
setRunningRoutineId(id);
},
onSuccess: async (_, { id }) => {
setRunDialogRoutine(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
]);
},
onSettled: () => {
setRunningRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Routine run failed",
body: mutationError instanceof Error ? mutationError.message : `${VOCAB.appName} could not start the routine run.`,
tone: "error",
});
},
});
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
(projects ?? []).map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[projects],
);
const agentById = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent])),
[agents],
);
const projectById = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const needsConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
if (needsConfiguration) {
setRunDialogRoutine(routine);
return;
}
runRoutine.mutate({ id: routine.id, data: {} });
}
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message={`Select a ${VOCAB.company.toLowerCase()} to view routines.`} />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
Routines
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">Beta</span>
</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution issues.
</p>
</div>
<Button onClick={() => setComposerOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
<Dialog
open={composerOpen}
onOpenChange={(open) => {
if (!createRoutine.isPending) {
setComposerOpen(open);
}
}}
>
<DialogContent
showCloseButton={false}
className="flex max-h-[calc(100dvh-2rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0"
>
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground">
Define the recurring work first. Trigger setup comes next on the detail page.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setComposerOpen(false);
setAdvancedOpen(false);
}}
disabled={createRoutine.isPending}
>
Cancel
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
/>
</div>
<div className="px-5 pb-3">
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "var(--muted-foreground)" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "var(--muted-foreground)" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
</div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
<div className="mt-3 space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
description={draft.description}
value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
/>
</div>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
After creation, {VOCAB.appName} takes you straight to trigger setup for schedules, webhooks, or internal runs.
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim() ||
!draft.projectId ||
!draft.assigneeAgentId
}
>
<Plus className="mr-2 h-4 w-4" />
{createRoutine.isPending ? "Creating..." : "Create routine"}
</Button>
{createRoutine.isError ? (
<p className="text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
{error ? (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"}
</CardContent>
</Card>
) : null}
<div>
{(routines ?? []).length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message="No routines yet. Use Create routine to define the first recurring workflow."
/>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b border-border">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Project</th>
<th className="px-3 py-2 font-medium">Agent</th>
<th className="px-3 py-2 font-medium">Last run</th>
<th className="px-3 py-2 font-medium">Enabled</th>
<th className="w-12 px-3 py-2" />
</tr>
</thead>
<tbody>
{(routines ?? []).map((routine) => {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
return (
<tr
key={routine.id}
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
onClick={() => navigate(`/routines/${routine.id}`)}
>
<td className="px-3 py-2.5">
<div className="min-w-[180px]">
<span className="font-medium">
{routine.title}
</span>
{(isArchived || routine.status === "paused") && (
<div className="mt-1 text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"}
</div>
)}
</div>
</td>
<td className="px-3 py-2.5">
{routine.projectId ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "var(--primary)" }}
/>
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5">
{routine.assigneeAgentId ? (() => {
const agent = agentById.get(routine.assigneeAgentId);
return agent ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{agent.name}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
);
})() : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
{routine.lastRun ? (
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
) : null}
</td>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={enabled}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
disabled={isStatusPending || isArchived}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-foreground" : "bg-muted"
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
})
}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => handleRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: enabled ? "paused" : "active",
})
}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
})
}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
if (!next) setRunDialogRoutine(null);
}}
companyId={selectedCompanyId}
project={runDialogProject}
variables={runDialogRoutine?.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => {
if (!runDialogRoutine) return;
runRoutine.mutate({ id: runDialogRoutine.id, data });
}}
/>
</div>
);
}