Three coordinated changes after reviewing the wave 2 subagent reports:
1. Restore Presentations as the 9th Studio workshop.
Phase 10's subagent dropped Presentations from the workshop grid
because the spec's eight-workshop list didn't include it. But
.planning/PROJECT.md explicitly lists "Presentations & video
generation via Remotion" as an Active v1.7 requirement and the
existing PresentationPanel.tsx is already a real working Remotion
generator. Dropping it was silent feature regression.
- workshops.ts: add "presentations" slug + Presentation Lucide
icon, placed between social and convert in canonical order
- classifyIntent.ts: add pitch-deck / slide-deck / demo-video /
keynote intent routing (before social so "pitch deck" wins)
- StudioWorkshopDetail.tsx: import PresentationPanel and add a
"presentation" case in WorkshopBody
- workshops.test.ts: expected canonical order updated to 9 slugs
- classifyIntent.test.ts: 4 new parameterized rows for presentations
- WorkshopCard.test.tsx: index 7 is now PRESENTATIONS, 8 is CONVERT
- WorkshopGrid.test.tsx: expected card count 9, canonical order
2. ProjectCard hero-stat derivatives instead of em-dash city.
The shared Project record has none of the fields the spec §7.1 card
depends on: milestoneProgress, nextGate, costBurned, per-project
agent count, phase/milestone array. Wave 2 shipped every card with
"—%" and blank hero numbers — visually underwhelming for a layout
whose whole point is the 72px volt performance stat.
Compute best-effort proxies on the client from data that exists:
- progress: closed_issues / total_issues × 100, from a single
issuesApi.list(companyId) query grouped by projectId
- nextGateName: first pending approval whose payload.projectId
matches, from a single approvalsApi.list(companyId, "pending")
query
- lastActivity: max(project.updatedAt, newest issue.updatedAt in
the project), rendered as "8m ago"-style diff
Each proxy is annotated with // TODO(phase-11.5) for replacement
when real backend aggregates land. phase, costBurnedCents, and
per-project agent count remain hard gaps — rendered as null which
the card surfaces as em-dashes. These three are explicitly queued
for Phase 11.5.
No backend changes; everything derives from existing endpoints.
Two new useQuery calls in Projects.tsx (issues + pending approvals)
both fire per-company, not per-project, so they stay cheap for the
~dozens-of-projects scale the list targets.
3. Spec updated 8 → 9 workshops everywhere it referred to the count.
docs/specs/2026-04-11-nexus-layout-overhaul.md:
- §2 IA table: 8 → 9 workshops
- §6 ASCII header: Eight → Nine
- §6.3 section title: Eight workshops → Nine workshops
- §11 decisions log #16: amendment note explaining the 8→9 bump
- §13 phase 10 description: 8-card → 9-card, with Presentations
explicitly called out
- "Folds into Studio as the 8th workshop" → "Folds into Studio
as a workshop (the legacy /convert route is preserved for
backwards compat)"
Verification: 75/75 studio tests passing; 52/52 projects tests
passing; tsc clean on studio/ + projects/ + Projects.tsx +
ProjectDetail.tsx + StudioWorkshopDetail.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
// [nexus] Phase 11 — Projects list page (hero-stat cards).
|
||
//
|
||
// Replaces the old Paperclip EntityRow table with the spec §7.1
|
||
// hero-stat card grid. Cards stack full-width on <1024px and 2-up on
|
||
// >=1024px. A forest-green "+ NEW PROJECT" CTA sits top-right. When
|
||
// the project list is empty we render the 96px "NO PROJECTS YET"
|
||
// empty state canvas.
|
||
//
|
||
// Phase 11.5 decision (wave 2.5 controller pass): the shared Project
|
||
// type is missing milestoneProgress / nextGate / costBurned / agent
|
||
// counts, but we don't want em-dash city to kill the visual impact of
|
||
// the 72px volt hero percentage. Instead we compute best-effort
|
||
// derivatives from data that DOES exist:
|
||
// - progress ← closed_issue_count / total_issue_count (from one
|
||
// shared issuesApi.list query, grouped per-project)
|
||
// - nextGateName ← first pending approval whose payload.projectId
|
||
// matches (from one shared approvalsApi.list query)
|
||
// - lastActivity ← max(project.updatedAt, newest issue activity in
|
||
// the project) rendered as "8m ago"-style diff
|
||
// Each proxy is marked with a `// TODO(phase-11.5)` comment and will
|
||
// be replaced by a real backend field when it lands.
|
||
import { useEffect, useMemo } from "react";
|
||
import { VOCAB } from "@paperclipai/branding";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useNavigate } from "@/lib/router";
|
||
import type { Issue, Approval } from "@paperclipai/shared";
|
||
import { projectsApi } from "../api/projects";
|
||
import { issuesApi } from "../api/issues";
|
||
import { approvalsApi } from "../api/approvals";
|
||
import { useCompany } from "../context/CompanyContext";
|
||
import { useDialog } from "../context/DialogContext";
|
||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||
import { queryKeys } from "../lib/queryKeys";
|
||
import { PageSkeleton } from "../components/PageSkeleton";
|
||
import { EmptyState } from "../components/EmptyState";
|
||
import { Hexagon } from "lucide-react";
|
||
import { projectUrl } from "../lib/utils";
|
||
import { cn } from "@/lib/utils";
|
||
import { ProjectCard, type ProjectCardStatus } from "../components/projects/ProjectCard";
|
||
|
||
/** Issue statuses that count as "closed" for progress computation. */
|
||
const CLOSED_ISSUE_STATUSES = new Set<string>(["done", "cancelled"]);
|
||
|
||
/**
|
||
* Derive per-project aggregates from a flat list of issues and pending
|
||
* approvals. Runs once on the client per Projects page render — cheap
|
||
* enough for the ~dozens-of-projects scale the list targets.
|
||
*
|
||
* TODO(phase-11.5): replace with server-side aggregates on the Project
|
||
* record when the backend exposes them.
|
||
*/
|
||
function buildProjectDerivatives(
|
||
issues: Issue[] | undefined,
|
||
pendingApprovals: Approval[] | undefined,
|
||
): Map<
|
||
string,
|
||
{
|
||
progress: number | null;
|
||
closedCount: number;
|
||
totalCount: number;
|
||
nextGateName: string | null;
|
||
latestActivityAt: Date | null;
|
||
}
|
||
> {
|
||
const map = new Map<
|
||
string,
|
||
{
|
||
progress: number | null;
|
||
closedCount: number;
|
||
totalCount: number;
|
||
nextGateName: string | null;
|
||
latestActivityAt: Date | null;
|
||
}
|
||
>();
|
||
|
||
for (const issue of issues ?? []) {
|
||
if (!issue.projectId) continue;
|
||
const row = map.get(issue.projectId) ?? {
|
||
progress: null,
|
||
closedCount: 0,
|
||
totalCount: 0,
|
||
nextGateName: null,
|
||
latestActivityAt: null,
|
||
};
|
||
row.totalCount += 1;
|
||
if (CLOSED_ISSUE_STATUSES.has(issue.status)) {
|
||
row.closedCount += 1;
|
||
}
|
||
// Track the newest issue update timestamp for lastActivity.
|
||
const issueUpdated = issue.updatedAt
|
||
? typeof issue.updatedAt === "string"
|
||
? new Date(issue.updatedAt)
|
||
: issue.updatedAt
|
||
: null;
|
||
if (
|
||
issueUpdated &&
|
||
(!row.latestActivityAt || issueUpdated.getTime() > row.latestActivityAt.getTime())
|
||
) {
|
||
row.latestActivityAt = issueUpdated;
|
||
}
|
||
map.set(issue.projectId, row);
|
||
}
|
||
|
||
for (const [projectId, row] of map.entries()) {
|
||
row.progress = row.totalCount > 0 ? (row.closedCount / row.totalCount) * 100 : null;
|
||
map.set(projectId, row);
|
||
}
|
||
|
||
for (const approval of pendingApprovals ?? []) {
|
||
// Approval.payload is untyped Record<string, unknown>; probe defensively.
|
||
const payload = approval.payload as Record<string, unknown> | undefined;
|
||
const projectId = typeof payload?.projectId === "string" ? payload.projectId : null;
|
||
if (!projectId) continue;
|
||
const row = map.get(projectId) ?? {
|
||
progress: null,
|
||
closedCount: 0,
|
||
totalCount: 0,
|
||
nextGateName: null,
|
||
latestActivityAt: null,
|
||
};
|
||
if (row.nextGateName === null) {
|
||
const title =
|
||
(typeof payload?.title === "string" && payload.title) ||
|
||
(typeof payload?.name === "string" && payload.name) ||
|
||
approval.type;
|
||
row.nextGateName = title;
|
||
}
|
||
map.set(projectId, row);
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
function formatRelative(then: Date | null): string | null {
|
||
if (!then) return null;
|
||
const ms = Date.now() - then.getTime();
|
||
const mins = Math.floor(ms / 60_000);
|
||
if (mins < 1) return "just now";
|
||
if (mins < 60) return `${mins}m ago`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs}h ago`;
|
||
const days = Math.floor(hrs / 24);
|
||
return `${days}d ago`;
|
||
}
|
||
|
||
function deriveStatus(status: string): ProjectCardStatus {
|
||
// Waiting > working > idle priority. For Phase 11 we only have the
|
||
// coarse ProjectStatus enum to work with; the finer-grained "has
|
||
// pending gate" / "has working agent" checks are data gaps.
|
||
if (status === "paused" || status === "awaiting_input") return "waiting";
|
||
if (status === "active" || status === "running") return "working";
|
||
return "idle";
|
||
}
|
||
|
||
export function Projects() {
|
||
const { selectedCompanyId } = useCompany();
|
||
const { openNewProject } = useDialog();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => {
|
||
setBreadcrumbs([{ label: "Projects" }]);
|
||
}, [setBreadcrumbs]);
|
||
|
||
const { data: allProjects, isLoading, error } = useQuery({
|
||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
|
||
// TODO(phase-11.5): replace with server-side aggregates on Project record.
|
||
// For now we fetch all issues and all pending approvals once and derive
|
||
// per-project counts on the client. This is O(#issues + #approvals) on the
|
||
// frontend but only fires per-company, not per-project, so it stays cheap.
|
||
const { data: allIssues } = useQuery({
|
||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
const { data: pendingApprovals } = useQuery({
|
||
queryKey: [...queryKeys.approvals.list(selectedCompanyId!), "pending"],
|
||
queryFn: () => approvalsApi.list(selectedCompanyId!, "pending"),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
|
||
const projects = useMemo(
|
||
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||
[allProjects],
|
||
);
|
||
|
||
const derivatives = useMemo(
|
||
() => buildProjectDerivatives(allIssues, pendingApprovals),
|
||
[allIssues, pendingApprovals],
|
||
);
|
||
|
||
if (!selectedCompanyId) {
|
||
return (
|
||
<EmptyState
|
||
icon={Hexagon}
|
||
message={`Select a ${VOCAB.company.toLowerCase()} to view projects.`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <PageSkeleton variant="list" />;
|
||
}
|
||
|
||
// Empty state — full-canvas 96px volt hero.
|
||
if (projects.length === 0) {
|
||
return (
|
||
<div
|
||
data-testid="projects-empty-state"
|
||
className="flex min-h-[60vh] flex-col items-start justify-center gap-8"
|
||
>
|
||
<h2
|
||
data-testid="projects-empty-hero"
|
||
className="font-black uppercase text-[96px] leading-none text-primary"
|
||
>
|
||
No projects yet
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={openNewProject}
|
||
data-testid="projects-empty-cta"
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-md px-5 py-3",
|
||
"bg-[#166534] text-white",
|
||
"text-[14px] font-semibold uppercase tracking-[0.1em]",
|
||
"hover:bg-[#166534]/90 transition-colors",
|
||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<span aria-hidden="true">⊕</span> Start your first project
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div data-testid="projects-page" className="space-y-6">
|
||
{/* Title row: section heading + new project CTA */}
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-[14px] font-bold uppercase tracking-[0.14em] text-muted-foreground">
|
||
Projects
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={openNewProject}
|
||
data-testid="projects-new-project-cta"
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-md px-4 py-2",
|
||
"bg-[#166534] text-white",
|
||
"text-[12px] font-semibold uppercase tracking-[0.12em]",
|
||
"hover:bg-[#166534]/90 transition-colors",
|
||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<span aria-hidden="true">⊕</span> New project
|
||
</button>
|
||
</div>
|
||
|
||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||
|
||
{/* Card grid: 1 col <1024px, 2 cols >=1024px, 16px gap */}
|
||
<div
|
||
data-testid="projects-grid"
|
||
className="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||
>
|
||
{projects.map((project) => {
|
||
const slug = (project.urlKey ?? project.name).toUpperCase();
|
||
const derived = derivatives.get(project.id);
|
||
|
||
// Take the newer of project.updatedAt and the latest issue
|
||
// update inside this project — whichever happened more recently.
|
||
const projectUpdatedAt = project.updatedAt
|
||
? typeof project.updatedAt === "string"
|
||
? new Date(project.updatedAt)
|
||
: project.updatedAt
|
||
: null;
|
||
const latestActivity =
|
||
derived?.latestActivityAt &&
|
||
(!projectUpdatedAt || derived.latestActivityAt.getTime() > projectUpdatedAt.getTime())
|
||
? derived.latestActivityAt
|
||
: projectUpdatedAt;
|
||
|
||
return (
|
||
<ProjectCard
|
||
key={project.id}
|
||
slug={slug}
|
||
status={deriveStatus(project.status)}
|
||
// TODO(phase-11.5): replace with real milestoneProgress from
|
||
// the Project record. For now progress is a proxy:
|
||
// closed_issues / total_issues × 100.
|
||
progress={derived?.progress ?? null}
|
||
// Phase / milestone data is a hard gap (no milestones on
|
||
// the Project record). Leave null — ProjectCard renders "—".
|
||
phase={null}
|
||
// TODO(phase-11.5): replace with the real next-gate slot on
|
||
// the Project record. Currently derived from the first
|
||
// pending approval whose payload.projectId matches.
|
||
nextGateName={derived?.nextGateName ?? null}
|
||
// costBurnedCents is still a hard gap in Wave 2 — deferred
|
||
// to phase 11.5.
|
||
costBurnedCents={null}
|
||
lastActivity={formatRelative(latestActivity)}
|
||
ariaLabel={`Open project ${slug}`}
|
||
onClick={() => navigate(projectUrl(project))}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|