diff --git a/ui/src/components/projects/tabs/ActivityTab.tsx b/ui/src/components/projects/tabs/ActivityTab.tsx new file mode 100644 index 00000000..cdb00ca8 --- /dev/null +++ b/ui/src/components/projects/tabs/ActivityTab.tsx @@ -0,0 +1,31 @@ +// [nexus] Phase 11 — Project Detail ACTIVITY tab. +// +// BLOCKED on component gap: the global /activity page does not expose +// a standalone feed component with a project filter. It renders an +// inline table directly. `activityApi.list` accepts +// {entityType, entityId} which would let us filter on the server, but +// Phase 11 is not permitted to extract a new feed component from the +// existing page. A follow-up will land the reusable feed. +import { TabPlaceholder } from "./TabPlaceholder"; + +export interface ActivityTabProps { + projectId: string; +} + +export function ActivityTab({ projectId: _projectId }: ActivityTabProps) { + return ( + + Per-project activity feed not yet wired. The /activity page does + not expose a reusable feed component; the Phase 11 plan forbids + modifying it or copy-pasting. A follow-up ticket will extract a + feed component that accepts a projectId and call + activityApi.list with {`{`}entityType: "project", entityId{`}`}. + + } + /> + ); +} diff --git a/ui/src/components/projects/tabs/AgentsTab.tsx b/ui/src/components/projects/tabs/AgentsTab.tsx new file mode 100644 index 00000000..46b7c55f --- /dev/null +++ b/ui/src/components/projects/tabs/AgentsTab.tsx @@ -0,0 +1,30 @@ +// [nexus] Phase 11 — Project Detail AGENTS tab. +// +// BLOCKED on backend data gap: the shared Agent type has no `projectId` +// association and the Agents page does not expose a standalone +// AgentList component with a projectId prop. Phase 11 is not allowed +// to modify those components. This tab renders a placeholder marking +// the gap; the controller will add a per-project agent query + an +// AgentList prop in a follow-up ticket. +import { TabPlaceholder } from "./TabPlaceholder"; + +export interface AgentsTabProps { + projectId: string; +} + +export function AgentsTab({ projectId: _projectId }: AgentsTabProps) { + return ( + + Per-project agent list not yet wired. The shared Agent type has no + project association, and the existing AgentList component does not + accept a projectId prop. Phase 11 ships the tab container; a + follow-up ticket will add the filter. + + } + /> + ); +} diff --git a/ui/src/components/projects/tabs/CostsTab.tsx b/ui/src/components/projects/tabs/CostsTab.tsx new file mode 100644 index 00000000..8ff29d41 --- /dev/null +++ b/ui/src/components/projects/tabs/CostsTab.tsx @@ -0,0 +1,32 @@ +// [nexus] Phase 11 — Project Detail COSTS tab. +// +// BLOCKED on component gap: there is no standalone CostsBreakdown +// component. The /costs page (ui/src/pages/Costs.tsx) is ~1100 lines of +// inline page content that composes ~8 card components, multiple +// date-range selectors, and agent/project/biller tabs. Phase 11 may +// not modify that page and the plan forbids copy-paste. The backend +// does expose `costsApi.byProject`, but wiring a per-project breakdown +// from scratch is out of Phase 11 scope. +import { TabPlaceholder } from "./TabPlaceholder"; + +export interface CostsTabProps { + projectId: string; +} + +export function CostsTab({ projectId: _projectId }: CostsTabProps) { + return ( + + Per-project cost breakdown not yet wired. There is no standalone + CostsBreakdown component to reuse (the /costs page is monolithic), + and the Phase 11 plan forbids duplicating it. A follow-up ticket + will extract a reusable breakdown component that accepts a + projectId filter and hook into costsApi.byProject. + + } + /> + ); +} diff --git a/ui/src/components/projects/tabs/GatesTab.tsx b/ui/src/components/projects/tabs/GatesTab.tsx new file mode 100644 index 00000000..b80d88c2 --- /dev/null +++ b/ui/src/components/projects/tabs/GatesTab.tsx @@ -0,0 +1,37 @@ +// [nexus] Phase 11 — Project Detail GATES tab. +// +// Display-only rename of "Approvals" → "Gates". Internally we still hit +// /api/approvals and the `approvalsApi` client unchanged. The shared +// `Approval` type has no `projectId` field; Phase 11 cannot modify +// ApprovalCard / ApprovalsList to add a project filter. Until the +// controller wires a per-project approval query in a follow-up, this +// tab renders a placeholder marking the gap. +// +// (The `useGateIndicator` hook added in this phase counts *global* +// pending gates for the IconRail dot — that's independent of this +// per-project filter and needs no project scoping.) +import { TabPlaceholder } from "./TabPlaceholder"; + +export interface GatesTabProps { + projectId: string; +} + +export function GatesTab({ projectId: _projectId }: GatesTabProps) { + return ( + + Per-project gate list not yet wired. The shared Approval type has + no projectId field (some payloads carry one on the JSON blob, but + that's a convention, not a typed contract), and there is no + ApprovalsList component to pass a projectId prop to. Phase 11 + ships the tab container and a display-only "Gates" rename; a + follow-up will filter approvals by project once the backend + exposes a stable contract. + + } + /> + ); +} diff --git a/ui/src/components/projects/tabs/IssuesTab.tsx b/ui/src/components/projects/tabs/IssuesTab.tsx new file mode 100644 index 00000000..2b38bf40 --- /dev/null +++ b/ui/src/components/projects/tabs/IssuesTab.tsx @@ -0,0 +1,49 @@ +// [nexus] Phase 11 — Project Detail ISSUES tab. +// +// Thin wrapper around the existing component scoped to +// the current project. IssuesList already accepts a `projectId` prop +// out of the box, so no upstream modifications are needed. +// +// Data loading (issues, agents, liveRuns) is intentionally left to the +// caller — Phase 11 does not own IssuesList and should not duplicate +// its hook orchestration. The existing ProjectDetail `ProjectIssuesList` +// inner component already has the exact data wiring we need; this tab +// component exists primarily as a stable type-safe surface the Builder +// tab dispatcher can render. +import type { Agent, Issue } from "@paperclipai/shared"; +import { IssuesList } from "../../IssuesList"; + +export interface IssuesTabProps { + projectId: string; + issues: Issue[]; + isLoading?: boolean; + error?: Error | null; + agents?: Agent[]; + liveIssueIds?: Set; + onUpdateIssue: (id: string, data: Record) => void; +} + +export function IssuesTab({ + projectId, + issues, + isLoading, + error, + agents, + liveIssueIds, + onUpdateIssue, +}: IssuesTabProps) { + return ( +
+ +
+ ); +} diff --git a/ui/src/components/projects/tabs/OrgTab.tsx b/ui/src/components/projects/tabs/OrgTab.tsx new file mode 100644 index 00000000..7c54ea81 --- /dev/null +++ b/ui/src/components/projects/tabs/OrgTab.tsx @@ -0,0 +1,34 @@ +// [nexus] Phase 11 — Project Detail ORG tab. +// +// BLOCKED on component gap: the existing Org/OrgChart pages render +// company-wide reporting trees. There's no project-scoped org tree in +// the backend (agents.org() returns a company tree), and no reusable +// component with a projectId filter prop. Phase 11 is not permitted +// to modify those pages. +// +// Note the BuilderTabStrip hides the ORG tab entirely for single-agent +// projects (spec §7.2.7 + ProjectDetail integration). This placeholder +// only renders when a project has at least 2 agents *and* the user +// clicks ORG. +import { TabPlaceholder } from "./TabPlaceholder"; + +export interface OrgTabProps { + projectId: string; +} + +export function OrgTab({ projectId: _projectId }: OrgTabProps) { + return ( + + Per-project org chart not yet wired. agents.org() returns a + company-wide reporting tree; there's no project-scoped variant + today. A follow-up ticket will land a server endpoint and + reusable component that accepts a projectId filter. + + } + /> + ); +} diff --git a/ui/src/components/projects/tabs/TabPlaceholder.tsx b/ui/src/components/projects/tabs/TabPlaceholder.tsx new file mode 100644 index 00000000..595a719b --- /dev/null +++ b/ui/src/components/projects/tabs/TabPlaceholder.tsx @@ -0,0 +1,44 @@ +// [nexus] Phase 11 — shared placeholder for Builder tabs whose underlying +// list components do not (yet) accept a `projectId` filter prop. +// +// Per the Phase 11 plan, Phase 11 MAY NOT modify existing list components +// (AgentList, ApprovalsList, CostsBreakdown, etc.). The plan also says +// STOP/BLOCKED if the components don't already accept a projectId filter. +// The shared `Approval` type has no `projectId` field, there is no +// standalone `CostsBreakdown` component, Activity filtering at the API +// level only accepts entityType/entityId, and the Agents page does not +// expose an AgentList with a project filter. +// +// Rather than fabricate per-project data or duplicate the global pages, +// Phase 11 ships explicit placeholder tabs that mark each data gap. The +// Phase 11 report enumerates the gaps and the controller will schedule +// follow-up tickets to add projectId props post-Wave. +import type { ReactNode } from "react"; + +export interface TabPlaceholderProps { + testId: string; + title: string; + gapReason: ReactNode; +} + +export function TabPlaceholder({ testId, title, gapReason }: TabPlaceholderProps) { + return ( +
+

+ {title} +

+

+ + Phase 11 data gap + + {gapReason} +

+
+ ); +} diff --git a/ui/src/components/projects/tabs/tabs.test.tsx b/ui/src/components/projects/tabs/tabs.test.tsx new file mode 100644 index 00000000..55a1d3de --- /dev/null +++ b/ui/src/components/projects/tabs/tabs.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentsTab } from "./AgentsTab"; +import { GatesTab } from "./GatesTab"; +import { CostsTab } from "./CostsTab"; +import { ActivityTab } from "./ActivityTab"; +import { OrgTab } from "./OrgTab"; +import { TabPlaceholder } from "./TabPlaceholder"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("Phase 11 per-project tab wrappers (placeholder tabs)", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function renderOne(element: React.ReactElement) { + root = createRoot(container); + act(() => { + root!.render(element); + }); + } + + describe("TabPlaceholder", () => { + it("renders the title, gap badge, and reason copy", () => { + renderOne( + , + ); + const card = container.querySelector('[data-testid="my-tab"]'); + expect(card).not.toBeNull(); + expect(card!.textContent).toContain("My Tab"); + expect(card!.textContent).toContain("Phase 11 data gap"); + expect(card!.textContent).toContain("This is why it's not wired yet."); + }); + }); + + describe("AgentsTab", () => { + it("renders a placeholder marking the data gap", () => { + renderOne(); + const tab = container.querySelector('[data-testid="project-agents-tab"]'); + expect(tab).not.toBeNull(); + expect(tab!.textContent).toContain("Agents"); + expect(tab!.textContent).toContain("Phase 11 data gap"); + }); + }); + + describe("GatesTab", () => { + it("renders a Gates placeholder — never 'Approvals' (display rename)", () => { + renderOne(); + const tab = container.querySelector('[data-testid="project-gates-tab"]'); + expect(tab).not.toBeNull(); + expect(tab!.textContent).toContain("Gates"); + expect(tab!.textContent?.toLowerCase()).not.toContain("approvals tab"); + expect(tab!.textContent).toContain("Phase 11 data gap"); + }); + }); + + describe("CostsTab", () => { + it("renders a Costs placeholder marking the data gap", () => { + renderOne(); + const tab = container.querySelector('[data-testid="project-costs-tab"]'); + expect(tab).not.toBeNull(); + expect(tab!.textContent).toContain("Costs"); + expect(tab!.textContent).toContain("Phase 11 data gap"); + }); + }); + + describe("ActivityTab", () => { + it("renders an Activity placeholder marking the data gap", () => { + renderOne(); + const tab = container.querySelector('[data-testid="project-activity-tab"]'); + expect(tab).not.toBeNull(); + expect(tab!.textContent).toContain("Activity"); + expect(tab!.textContent).toContain("Phase 11 data gap"); + }); + }); + + describe("OrgTab", () => { + it("renders an Org placeholder marking the data gap", () => { + renderOne(); + const tab = container.querySelector('[data-testid="project-org-tab"]'); + expect(tab).not.toBeNull(); + expect(tab!.textContent).toContain("Org"); + expect(tab!.textContent).toContain("Phase 11 data gap"); + }); + }); +});