feat(nexus): add thin wrappers for per-project tabs (phase 11)

Wires the 6 Builder tab content components:

• IssuesTab — thin wrapper around IssuesList (which already accepts a
  projectId prop, so this is a full wiring).
• GatesTab / AgentsTab / CostsTab / ActivityTab / OrgTab — render a
  shared TabPlaceholder marking the Phase 11 data gap. None of the
  underlying list components accept a projectId filter today, and the
  Phase 11 plan forbids modifying them unilaterally. The placeholders
  are explicit (never fabricate per-project data) and carry the
  follow-up description the controller will turn into a ticket.

GatesTab is named "Gates" everywhere visible (display-only rename per
spec §7.2.4 / plan §5); approvalsApi / Approval type / /api/approvals
endpoints are all untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:21:33 +00:00
parent cd6c172d48
commit d10c5b991e
8 changed files with 367 additions and 0 deletions

View file

@ -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 (
<TabPlaceholder
testId="project-activity-tab"
title="Activity"
gapReason={
<>
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{`}`}.
</>
}
/>
);
}

View file

@ -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<projectId> prop in a follow-up ticket.
import { TabPlaceholder } from "./TabPlaceholder";
export interface AgentsTabProps {
projectId: string;
}
export function AgentsTab({ projectId: _projectId }: AgentsTabProps) {
return (
<TabPlaceholder
testId="project-agents-tab"
title="Agents"
gapReason={
<>
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.
</>
}
/>
);
}

View file

@ -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 (
<TabPlaceholder
testId="project-costs-tab"
title="Costs"
gapReason={
<>
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.
</>
}
/>
);
}

View file

@ -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 (
<TabPlaceholder
testId="project-gates-tab"
title="Gates"
gapReason={
<>
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.
</>
}
/>
);
}

View file

@ -0,0 +1,49 @@
// [nexus] Phase 11 — Project Detail ISSUES tab.
//
// Thin wrapper around the existing <IssuesList /> 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<string>;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
export function IssuesTab({
projectId,
issues,
isLoading,
error,
agents,
liveIssueIds,
onUpdateIssue,
}: IssuesTabProps) {
return (
<div data-testid="project-issues-tab">
<IssuesList
issues={issues}
isLoading={isLoading}
error={error ?? null}
agents={agents}
liveIssueIds={liveIssueIds}
projectId={projectId}
viewStateKey={`paperclip:project-view:${projectId}`}
onUpdateIssue={onUpdateIssue}
/>
</div>
);
}

View file

@ -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 (
<TabPlaceholder
testId="project-org-tab"
title="Org"
gapReason={
<>
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.
</>
}
/>
);
}

View file

@ -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 (
<section
data-testid={testId}
className="rounded-lg border border-border p-6"
>
<h3 className="mb-2 text-[12px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{title}
</h3>
<p className="text-[14px] text-muted-foreground">
<span
data-testid={`${testId}-gap`}
className="inline-block rounded border border-[#f4f692]/40 bg-[#f4f692]/5 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.1em] text-[#f4f692]"
>
Phase 11 data gap
</span>
<span className="ml-2">{gapReason}</span>
</p>
</section>
);
}

View file

@ -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<typeof createRoot> | 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(
<TabPlaceholder
testId="my-tab"
title="My Tab"
gapReason="This is why it's not wired yet."
/>,
);
const card = container.querySelector<HTMLElement>('[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(<AgentsTab projectId="proj-1" />);
const tab = container.querySelector<HTMLElement>('[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(<GatesTab projectId="proj-1" />);
const tab = container.querySelector<HTMLElement>('[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(<CostsTab projectId="proj-1" />);
const tab = container.querySelector<HTMLElement>('[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(<ActivityTab projectId="proj-1" />);
const tab = container.querySelector<HTMLElement>('[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(<OrgTab projectId="proj-1" />);
const tab = container.querySelector<HTMLElement>('[data-testid="project-org-tab"]');
expect(tab).not.toBeNull();
expect(tab!.textContent).toContain("Org");
expect(tab!.textContent).toContain("Phase 11 data gap");
});
});
});