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 { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
import { useNexusMode } from "./hooks/useNexusMode";
|
import { useNexusMode } from "./hooks/useNexusMode";
|
||||||
import { loadLastInboxTab } from "./lib/inbox";
|
|
||||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
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 AgentDetail = lazy(() => import("./pages/AgentDetail").then(m => ({ default: m.AgentDetail })));
|
||||||
const Projects = lazy(() => import("./pages/Projects").then(m => ({ default: m.Projects })));
|
const Projects = lazy(() => import("./pages/Projects").then(m => ({ default: m.Projects })));
|
||||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail").then(m => ({ default: m.ProjectDetail })));
|
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 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 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 CompanySettings = lazy(() => import("./pages/CompanySettings").then(m => ({ default: m.CompanySettings })));
|
||||||
const CompanySkills = lazy(() => import("./pages/CompanySkills").then(m => ({ default: m.CompanySkills })));
|
const CompanySkills = lazy(() => import("./pages/CompanySkills").then(m => ({ default: m.CompanySkills })));
|
||||||
const CompanyExport = lazy(() => import("./pages/CompanyExport").then(m => ({ default: m.CompanyExport })));
|
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 PluginSettings = lazy(() => import("./pages/PluginSettings").then(m => ({ default: m.PluginSettings })));
|
||||||
const PluginPage = lazy(() => import("./pages/PluginPage").then(m => ({ default: m.PluginPage })));
|
const PluginPage = lazy(() => import("./pages/PluginPage").then(m => ({ default: m.PluginPage })));
|
||||||
const RunTranscriptUxLab = lazy(() => import("./pages/RunTranscriptUxLab").then(m => ({ default: m.RunTranscriptUxLab })));
|
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 NewAgent = lazy(() => import("./pages/NewAgent").then(m => ({ default: m.NewAgent })));
|
||||||
const AuthPage = lazy(() => import("./pages/Auth").then(m => ({ default: m.AuthPage })));
|
const AuthPage = lazy(() => import("./pages/Auth").then(m => ({ default: m.AuthPage })));
|
||||||
const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then(m => ({ default: m.BoardClaimPage })));
|
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 NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage })));
|
||||||
const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant })));
|
const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant })));
|
||||||
const ContentStudio = lazy(() => import("./pages/ContentStudio").then(m => ({ default: m.ContentStudio })));
|
const ContentStudio = lazy(() => import("./pages/ContentStudio").then(m => ({ default: m.ContentStudio })));
|
||||||
const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage })));
|
|
||||||
|
|
||||||
function BootstrapPendingPage({
|
function BootstrapPendingPage({
|
||||||
hasActiveInvite = false,
|
hasActiveInvite = false,
|
||||||
|
|
@ -156,10 +140,8 @@ function CloudAccessGate() {
|
||||||
function boardRoutes() {
|
function boardRoutes() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route index element={<Navigate to="dashboard" replace />} />
|
<Route index element={<Navigate to="assistant" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||||
<Route path="companies" element={<Companies />} />
|
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="company/export/*" element={<CompanyExport />} />
|
<Route path="company/export/*" element={<CompanyExport />} />
|
||||||
<Route path="company/import" element={<CompanyImport />} />
|
<Route path="company/import" element={<CompanyImport />} />
|
||||||
|
|
@ -167,12 +149,12 @@ function boardRoutes() {
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||||
<Route path="org" element={<OrgChart />} />
|
{/* Phase 16b: /agents top-level redirects to /projects. Agent detail pages remain. */}
|
||||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
<Route path="agents" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="agents/all" element={<Agents />} />
|
<Route path="agents/all" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="agents/active" element={<Agents />} />
|
<Route path="agents/active" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="agents/paused" element={<Agents />} />
|
<Route path="agents/paused" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="agents/error" element={<Agents />} />
|
<Route path="agents/error" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="agents/new" element={<NewAgent />} />
|
<Route path="agents/new" element={<NewAgent />} />
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
<Route path="agents/:agentId/:tab" 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/org" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
{/* Phase 16b: /issues top-level redirects to /projects. Issue detail pages remain. */}
|
||||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
<Route path="issues" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/all" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/active" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/backlog" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="issues/recent" element={<Navigate to="/issues" 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="issues/:issueId" element={<IssueDetail />} />
|
||||||
<Route path="routines" element={<Routines />} />
|
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<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" element={<PersonalAssistant />} />
|
||||||
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
|
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
|
||||||
<Route path="content-studio" element={<ContentStudio />} />
|
<Route path="content-studio" element={<ContentStudio />} />
|
||||||
<Route path="content-studio/:workshopSlug" element={<ContentStudio />} />
|
<Route path="content-studio/:workshopSlug" element={<ContentStudio />} />
|
||||||
<Route path="convert" element={<ConvertPage />} />
|
{/* Phase 16b: /convert folded into Studio Convert workshop. */}
|
||||||
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
|
<Route path="convert" element={<Navigate to="content-studio/convert" replace />} />
|
||||||
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
|
<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="design-guide" element={<DesignGuide />} />
|
||||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||||
|
|
@ -228,10 +196,6 @@ function boardRoutes() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InboxRootRedirect() {
|
|
||||||
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LegacySettingsRedirect() {
|
function LegacySettingsRedirect() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
|
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
|
||||||
|
|
@ -264,13 +228,13 @@ function OnboardingRoutePage() {
|
||||||
const title = matchedCompany
|
const title = matchedCompany
|
||||||
? `Add another agent to ${matchedCompany.name}`
|
? `Add another agent to ${matchedCompany.name}`
|
||||||
: companies.length > 0
|
: companies.length > 0
|
||||||
? "Create another company"
|
? "Create another workspace"
|
||||||
: "Create your first company";
|
: "Create your first workspace";
|
||||||
const description = matchedCompany
|
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
|
: companies.length > 0
|
||||||
? "Run onboarding again to create another company and seed its first agent."
|
? "Run onboarding again to create another workspace and seed its first agent."
|
||||||
: "Get started by creating a company and your first agent.";
|
: "Get started by creating a workspace and your first agent.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<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
|
// [nexus] Nexus-first landing: in personal_ai / both (default) modes, land
|
||||||
// on the Personal Assistant. Only project_builder mode lands on the board
|
// on the Personal Assistant. project_builder mode lands on projects. The
|
||||||
// dashboard. URL overrides (typing /PREFIX/dashboard) are still honored.
|
// legacy dashboard route is deleted in Phase 16b.
|
||||||
const landingPath = mode === "project_builder" ? "dashboard" : "assistant";
|
const landingPath = mode === "project_builder" ? "projects" : "assistant";
|
||||||
return <Navigate to={`/${targetCompany.issuePrefix}/${landingPath}`} replace />;
|
return <Navigate to={`/${targetCompany.issuePrefix}/${landingPath}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,15 +351,10 @@ export function App() {
|
||||||
<Route path="plugins" element={<PluginManager />} />
|
<Route path="plugins" element={<PluginManager />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
|
||||||
<Route path="issues/:issueId" 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="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
|
||||||
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,6 @@ import {
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Bot,
|
Bot,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
Target,
|
|
||||||
LayoutDashboard,
|
|
||||||
Inbox,
|
|
||||||
DollarSign,
|
|
||||||
History,
|
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -192,46 +187,18 @@ export function CommandPalette() {
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
|
|
||||||
<CommandGroup heading="Pages">
|
<CommandGroup heading="Pages">
|
||||||
<CommandItem onSelect={() => go("/dashboard")}>
|
<CommandItem onSelect={() => go("/assistant")}>
|
||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
Dashboard
|
Assistant
|
||||||
</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>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => go("/projects")}>
|
<CommandItem onSelect={() => go("/projects")}>
|
||||||
<Hexagon className="mr-2 h-4 w-4" />
|
<Hexagon className="mr-2 h-4 w-4" />
|
||||||
Projects
|
Projects
|
||||||
</CommandItem>
|
</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")}>
|
<CommandItem onSelect={() => go("/content-studio")}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
Content Studio
|
Content Studio
|
||||||
</CommandItem>
|
</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>
|
</CommandGroup>
|
||||||
|
|
||||||
{visibleIssues.length > 0 && (
|
{visibleIssues.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ function SortableCompanyItem({
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<a
|
<a
|
||||||
href={`/${company.issuePrefix}/dashboard`}
|
href={`/${company.issuePrefix}/assistant`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect();
|
onSelect();
|
||||||
|
|
@ -295,7 +295,7 @@ export function CompanyRail() {
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedCompanyId(company.id);
|
setSelectedCompanyId(company.id);
|
||||||
if (isInstanceRoute) {
|
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
|
// "/NEX/assistant" — Phase 33 (v1.5) and Phase 40+ (v1.7) introduced
|
||||||
// routes without updating this set.
|
// routes without updating this set.
|
||||||
const BOARD_ROUTE_ROOTS = new Set([
|
const BOARD_ROUTE_ROOTS = new Set([
|
||||||
"dashboard",
|
|
||||||
"companies",
|
|
||||||
"company",
|
"company",
|
||||||
"skills",
|
"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",
|
"agents",
|
||||||
"projects",
|
"projects",
|
||||||
"execution-workspaces",
|
"execution-workspaces",
|
||||||
|
// Phase 16b: /issues top-level is redirected to /projects, but issue
|
||||||
|
// detail pages (/issues/:issueId) still live here.
|
||||||
"issues",
|
"issues",
|
||||||
"routines",
|
|
||||||
"goals",
|
|
||||||
"approvals",
|
|
||||||
"costs",
|
|
||||||
"usage",
|
|
||||||
"activity",
|
|
||||||
"inbox",
|
|
||||||
"design-guide",
|
"design-guide",
|
||||||
// v1.5 Nexus Personal Assistant
|
// v1.5 Nexus Personal Assistant
|
||||||
"assistant",
|
"assistant",
|
||||||
// v1.7 Content Generation
|
// v1.7 Content Generation
|
||||||
"content-studio",
|
"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",
|
"convert",
|
||||||
// Dev / internal tools also under the board scope
|
// Dev / internal tools also under the board scope
|
||||||
"plugins",
|
"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