From c417ce37f9c35f6d06d6204e06f3326547218516 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:23:20 +0000 Subject: [PATCH] feat(nexus): add Skills and Routines settings cards (phase 13) SkillsSection surfaces installed skill and skill-group counts and links out to the existing Skill Aggregator (CompanySkills page) at //skills. The full browse/install/assign UI stays put; the section is a compact summary + entry point per plan recommendation. RoutinesSection renders the top five routines as a compact read-only list (title, cron label, status) and links out to //routines for the full interactive editor. Single responsibility per page, matches the plan's recommendation (a) for Routines fold-in. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/RoutinesSection.test.tsx | 148 ++++++++++++++++++ .../components/settings/RoutinesSection.tsx | 106 +++++++++++++ .../settings/SkillsSection.test.tsx | 104 ++++++++++++ ui/src/components/settings/SkillsSection.tsx | 72 +++++++++ 4 files changed, 430 insertions(+) create mode 100644 ui/src/components/settings/RoutinesSection.test.tsx create mode 100644 ui/src/components/settings/RoutinesSection.tsx create mode 100644 ui/src/components/settings/SkillsSection.test.tsx create mode 100644 ui/src/components/settings/SkillsSection.tsx diff --git a/ui/src/components/settings/RoutinesSection.test.tsx b/ui/src/components/settings/RoutinesSection.test.tsx new file mode 100644 index 00000000..aeb5b75e --- /dev/null +++ b/ui/src/components/settings/RoutinesSection.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "@/lib/router"; + +vi.mock("@/api/routines", () => ({ + routinesApi: { + list: vi.fn(async () => [ + { + id: "r1", + companyId: "c1", + projectId: "p1", + goalId: null, + parentIssueId: null, + title: "Daily summary", + description: null, + assigneeAgentId: "a1", + priority: "normal", + status: "active", + concurrencyPolicy: "skip_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + triggers: [ + { id: "t1", kind: "cron", label: "every 09:00", enabled: true, nextRunAt: null, lastFiredAt: null, lastResult: null }, + ], + lastRun: null, + activeIssue: null, + }, + { + id: "r2", + companyId: "c1", + projectId: "p1", + goalId: null, + parentIssueId: null, + title: "Cost report", + description: null, + assigneeAgentId: "a1", + priority: "normal", + status: "paused", + concurrencyPolicy: "skip_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + triggers: [], + lastRun: null, + activeIssue: null, + }, + ]), + }, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompanyId: "c1", + selectedCompany: { id: "c1", name: "Test", issuePrefix: "NEX" }, + selectionSource: "manual" as const, + loading: false, + error: null, + setSelectedCompanyId: () => {}, + reloadCompanies: async () => {}, + createCompany: async () => { + throw new Error("not implemented"); + }, + }), +})); + +import { RoutinesSection } from "./RoutinesSection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("RoutinesSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + queryClient.clear(); + }); + + async function renderAndFlush() { + root = createRoot(container); + act(() => { + root!.render( + + + + + , + ); + }); + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + } + + it("renders a compact list of routines with title, schedule, and status", async () => { + await renderAndFlush(); + expect(container.querySelector("h2")?.textContent).toBe("Routines"); + expect(container.textContent).toContain("Daily summary"); + expect(container.textContent).toContain("every 09:00"); + expect(container.textContent).toContain("Active"); + expect(container.textContent).toContain("Cost report"); + expect(container.textContent).toContain("Paused"); + }); + + it("links to the company-prefixed Routines page", async () => { + await renderAndFlush(); + const link = container.querySelector("a[aria-label='Open Routines page']"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe("/NEX/routines"); + }); +}); diff --git a/ui/src/components/settings/RoutinesSection.tsx b/ui/src/components/settings/RoutinesSection.tsx new file mode 100644 index 00000000..b85770db --- /dev/null +++ b/ui/src/components/settings/RoutinesSection.tsx @@ -0,0 +1,106 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Repeat } from "lucide-react"; +import { Link } from "@/lib/router"; +import { routinesApi } from "@/api/routines"; +import { useCompany } from "@/context/CompanyContext"; +import { Button } from "@/components/ui/button"; +import { SettingsSection } from "./SettingsSection"; + +function statusLabel(status: string) { + switch (status) { + case "active": + return "Active"; + case "paused": + return "Paused"; + case "archived": + return "Archived"; + default: + return status; + } +} + +export function RoutinesSection() { + const { selectedCompany } = useCompany(); + const companyId = selectedCompany?.id ?? null; + const issuePrefix = selectedCompany?.issuePrefix ?? null; + + const routinesQuery = useQuery({ + queryKey: ["settings", "routines", companyId ?? "none"], + queryFn: () => + companyId ? routinesApi.list(companyId) : Promise.resolve([]), + enabled: Boolean(companyId), + }); + + const routines = routinesQuery.data ?? []; + const topRoutines = routines.slice(0, 5); + + return ( + + {!companyId ? ( +

Select a workspace to see routines.

+ ) : routinesQuery.isLoading ? ( +

Loading routines...

+ ) : routinesQuery.error ? ( +
+ {routinesQuery.error instanceof Error + ? routinesQuery.error.message + : "Failed to load routines."} +
+ ) : routines.length === 0 ? ( +

+ No routines yet. Create one from the Routines page. +

+ ) : ( +
    + {topRoutines.map((routine) => { + const primaryTrigger = + routine.triggers.find((t) => t.enabled) ?? routine.triggers[0] ?? null; + const scheduleLabel = + primaryTrigger?.label ?? + (primaryTrigger?.cronExpression + ? `cron: ${primaryTrigger.cronExpression}` + : null); + return ( +
  • +
    +
    {routine.title}
    + {scheduleLabel ? ( +
    + {scheduleLabel} +
    + ) : null} +
    + + {statusLabel(routine.status)} + +
  • + ); + })} + {routines.length > topRoutines.length ? ( +
  • + + {routines.length - topRoutines.length} more +
  • + ) : null} +
+ )} + + +
+ ); +} diff --git a/ui/src/components/settings/SkillsSection.test.tsx b/ui/src/components/settings/SkillsSection.test.tsx new file mode 100644 index 00000000..c9afdeea --- /dev/null +++ b/ui/src/components/settings/SkillsSection.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "@/lib/router"; + +vi.mock("@/api/skillRegistry", () => ({ + skillRegistryApi: { + list: vi.fn(async () => [ + { id: "a/one", name: "One", description: null, sourceId: "a", category: null, activeVersionId: "v1", removedAt: null, averageRating: null, ratingCount: null, taskCount: null, avgCostUsd: null, lastUsedAt: null }, + { id: "a/two", name: "Two", description: null, sourceId: "a", category: null, activeVersionId: "v1", removedAt: null, averageRating: null, ratingCount: null, taskCount: null, avgCostUsd: null, lastUsedAt: null }, + { id: "a/removed", name: "R", description: null, sourceId: "a", category: null, activeVersionId: null, removedAt: 123, averageRating: null, ratingCount: null, taskCount: null, avgCostUsd: null, lastUsedAt: null }, + ]), + }, +})); + +vi.mock("@/api/skillGroups", () => ({ + skillGroupsApi: { + listGroups: vi.fn(async () => [{ id: "g1" }, { id: "g2" }]), + }, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompanyId: "c1", + selectedCompany: { id: "c1", name: "Test", issuePrefix: "NEX" }, + selectionSource: "manual" as const, + loading: false, + error: null, + setSelectedCompanyId: () => {}, + reloadCompanies: async () => {}, + createCompany: async () => { + throw new Error("not implemented"); + }, + }), +})); + +import { SkillsSection } from "./SkillsSection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("SkillsSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + queryClient.clear(); + }); + + async function renderAndFlush() { + root = createRoot(container); + act(() => { + root!.render( + + + + + , + ); + }); + for (let i = 0; i < 10; i++) { + await act(async () => { + await Promise.resolve(); + }); + } + } + + it("renders the Skills section and counts only non-removed skills", async () => { + await renderAndFlush(); + expect(container.querySelector("h2")?.textContent).toBe("Skills"); + expect(container.textContent).toContain("Installed skills"); + // Exactly 2 non-removed skills from the mock + const counts = Array.from(container.querySelectorAll("span")).filter( + (s) => s.textContent === "2", + ); + expect(counts.length).toBeGreaterThan(0); + }); + + it("links Open Skill Aggregator to the company-prefixed skills route", async () => { + await renderAndFlush(); + const openLink = container.querySelector("a[aria-label='Open Skill Aggregator']"); + expect(openLink).not.toBeNull(); + expect(openLink?.getAttribute("href")).toBe("/NEX/skills"); + }); +}); diff --git a/ui/src/components/settings/SkillsSection.tsx b/ui/src/components/settings/SkillsSection.tsx new file mode 100644 index 00000000..bb909081 --- /dev/null +++ b/ui/src/components/settings/SkillsSection.tsx @@ -0,0 +1,72 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Boxes } from "lucide-react"; +import { Link } from "@/lib/router"; +import { skillRegistryApi } from "@/api/skillRegistry"; +import { skillGroupsApi } from "@/api/skillGroups"; +import { useCompany } from "@/context/CompanyContext"; +import { Button } from "@/components/ui/button"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +export function SkillsSection() { + const { selectedCompany } = useCompany(); + + const registryQuery = useQuery({ + queryKey: ["settings", "skills", "registry"], + queryFn: () => skillRegistryApi.list(), + }); + + const groupsQuery = useQuery({ + queryKey: ["settings", "skills", "groups"], + queryFn: () => skillGroupsApi.listGroups(), + }); + + const installedCount = + (registryQuery.data ?? []).filter((s) => s.removedAt === null).length; + const groupCount = (groupsQuery.data ?? []).length; + + const skillsHref = selectedCompany + ? `/${selectedCompany.issuePrefix}/skills` + : "/instance/settings/general"; + + return ( + + + + {registryQuery.isLoading ? "..." : installedCount} + + + + + + {groupsQuery.isLoading ? "..." : groupCount} + + + + + + + + ); +}