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:
parent
548cfbdc41
commit
4d667caa1b
20 changed files with 38 additions and 7953 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">✓</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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">·</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
−
|
||||
</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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue