refactor(nexus): wave 2.5 follow-ups (presentations + proj derivatives)

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>
This commit is contained in:
Nexus Dev 2026-04-11 13:04:36 +00:00
parent 1b7e3d44fe
commit 428f033690
9 changed files with 216 additions and 34 deletions

View file

@ -23,7 +23,7 @@ In the new frame, **the Assistant is the canvas.** Everything else — Studio, P
| Slot | Destination | Route | Mental model |
|---|---|---|---|
| 1 | **Assistant** | `/assistant` | The home screen. Voice in, voice out. The chat IS the app. |
| 2 | **Studio** | `/studio` | Workshop selector for content generation (8 workshops). |
| 2 | **Studio** | `/studio` | Workshop selector for content generation (9 workshops). |
| 3 | **Projects** | `/projects` | List of all projects with health stats. |
| 4 | **Settings** | `/settings` | Workspace, Local AI, Cloud, Voice, Skills, Routines, Telegram, About. |
@ -58,7 +58,7 @@ These are scoped to one project. They are not addressable globally. Routes: `/pr
| 280px left sidebar with company switcher / sidebar projects / sidebar agents | Replaced by 56px icon rail |
| `MobileBottomNav` (company-aware) | Replaced by 4-icon bottom bar matching the desktop rail |
| Theme cycle button (Catppuccin/Tokyo/etc.) | Binary light/dark only, configured in Settings, no toggle in chrome |
| `ConvertPage` as a top-level route | Folds into Studio as the 8th workshop |
| `ConvertPage` as a top-level route | Folds into Studio as a workshop (the legacy `/convert` route is preserved for backwards compat) |
| `InboxRootRedirect`, `LegacySettingsRedirect`, `OnboardingRoutePage` URL machinery | Simpler routing |
| `PluginPage` as a top-level route slot | Plugin pages render inside Settings or inside a project |
| Any UI string containing "company", "companies", "tenant", "workspace member" | Vocabulary cleanup; replace with "workspace" or remove |
@ -304,7 +304,7 @@ Workshop selector for content generation. Replaces the current 7-tab `ContentStu
│ │ │
│ ⬢│ STUDIO │
│ │ ─ ─ ─ ─ ─ │
│ ◆│ Eight workshops. Pick one or describe what you need.
│ ◆│ Nine workshops. Pick one or describe what you need.
│ │ │
│ ▲│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ DIAGRAMS │ │ ICONS │ │ THEMES │ │
@ -335,7 +335,7 @@ Workshop selector for content generation. Replaces the current 7-tab `ContentStu
- **Icon glyph:** Lucide icon at 32×32 in volt, top-right of card.
- **Click:** navigates to `/studio/{workshop-slug}`.
### 6.3 Eight workshops
### 6.3 Nine workshops
| Slug | Title | Source | Notes |
|---|---|---|---|
@ -697,7 +697,7 @@ Captured from the brainstorming conversation 2026-04-11. These are binding choic
| 13 | No sleep / lock button | Approved (kill it) |
| 14 | Skill Aggregator lives in Settings → Skills section | Approved |
| 15 | Promote-to-project transition: chat compresses to 30%, brainstormer rises into bottom 70%, inset shadow ripple | Approved — "ship that exact animation" |
| 16 | Studio is a workshop grid (8 cards), not tabs | Approved |
| 16 | Studio is a workshop grid (9 cards), not tabs | Approved 2026-04-11; amended post-Wave-2 from 8 to 9 to restore Presentations (Active v1.7 req), see §6.3 |
| 17 | Convert folds into Studio as a workshop, no separate route | Approved |
| 18 | Project list cards use 72px Inter Black volt percentage as hero stat (DESIGN.md "performance stat" pattern) | Approved |
| 19 | ChatPanel as a global slide-in right rail is killed entirely | Approved |
@ -736,7 +736,7 @@ The visual migration plan ends at Phase 7 (visual QA of the ClickHouse repaint).
|---|---|---|---|
| **8** | **Frame skeleton** | New 56px icon rail, 48px top strip, killed sidebar/ChatPanel/PropertiesPanel as global elements. Routing simplified (no company prefix as a layout requirement). Old pages render in new frame but look weird — that's expected. | Foundational; blocks phases 915 |
| **9** | **Assistant mode** | Move PersonalAssistant.tsx to be the canonical Assistant route. Implement History (left) and Memory (right) slide-overs. Implement conversational home state for empty-conversation mode. Replace inline conversation column with slide-over. | Parallelizable with 10, 11 |
| **10** | **Studio mode** | Refactor ContentStudio.tsx from 7-tab to 8-card workshop grid. Fold ConvertPage in as 8th workshop. Add freeform Studio prompt with intent routing. Build workshop detail two-column layout. | Parallelizable with 9, 11 |
| **10** | **Studio mode** | Refactor ContentStudio.tsx from tabbed layout to a 9-card workshop grid. Fold ConvertPage in as a workshop. Keep the existing Presentations (Remotion) panel as a workshop. Add freeform Studio prompt with intent routing. Build workshop detail two-column layout. | Parallelizable with 9, 11 |
| **11** | **Projects + Builder mode** | Build new Projects list with hero-stat cards. Build Project Detail layout with 7-tab Builder strip. Demote global Issues/Agents/Approvals/Costs/Activity/Org/Goals/Inbox routes to per-project tabs. Rename Approvals → Gates. Reuse existing list components, scope by project ID. | Parallelizable with 9, 10 |
| **12** | **Promote-to-project transition** | The 700ms animation: chat compresses to 30%, brainstormer rises into 70%, inset shadow ripple, source-conversation label, post-creation banner linking chat to project. Origin chat preservation logic. | Depends on 9 (Assistant) and 11 (Projects) |
| **13** | **Settings consolidation** | Single-column Settings with all sections. Skills section (Skill Aggregator inline). Routines section (demoted from top-level). Re-run onboarding link. Drop nested settings routes. | Parallelizable with 12, 14, 15 |

View file

@ -86,8 +86,14 @@ describe("WorkshopCard", () => {
expect(svg.getAttribute("class") ?? "").toContain("text-primary");
});
it("renders the Convert workshop card (fold-in from /convert)", () => {
it("renders the Presentations workshop card (added post-Wave-2)", () => {
renderCard(7, () => {});
expect(container.textContent).toContain("PRESENTATIONS");
expect(container.textContent).toContain("Pitch decks");
});
it("renders the Convert workshop card (fold-in from /convert)", () => {
renderCard(8, () => {});
expect(container.textContent).toContain("CONVERT");
expect(container.textContent).toContain("File format conversion");
});

View file

@ -36,13 +36,13 @@ describe("WorkshopGrid", () => {
});
}
it("renders one card per workshop (8 total for Phase 10)", () => {
it("renders one card per workshop (9 total post-Wave-2)", () => {
renderGrid(() => {});
const cards = container.querySelectorAll("button[data-testid^='workshop-card-']");
expect(cards.length).toBe(8);
expect(cards.length).toBe(9);
});
it("renders the cards in canonical Phase 10 order", () => {
it("renders the cards in canonical post-Wave-2 order", () => {
renderGrid(() => {});
const cards = Array.from(
container.querySelectorAll("button[data-testid^='workshop-card-']"),
@ -58,6 +58,7 @@ describe("WorkshopGrid", () => {
"documents",
"brand-kits",
"social",
"presentations",
"convert",
]);
});

View file

@ -43,6 +43,12 @@ describe("classifyIntent", () => {
["I need a logo", "brand-kits"],
["style guide please", "brand-kits"],
// Presentations (checked before social — "pitch deck" is presentation, not social)
["build me a pitch deck about our platform", "presentations"],
["slide deck for the board", "presentations"],
["demo video of the onboarding flow", "presentations"],
["keynote-style presentation", "presentations"],
// Social
["twitter post about our launch", "social"],
["linkedin carousel on hiring", "social"],

View file

@ -61,6 +61,12 @@ export function classifyIntent(prompt: string): IntentClassification | null {
return { slug: "brand-kits", prefilledPrompt: prompt };
}
// Presentations (before social — "pitch deck" and "demo video" are
// strongly presentation-shaped even if they could feel socially adjacent)
if (/\b(presentation|pitch\s*deck|slide\s*deck|slides|demo\s*video|keynote)\b/.test(lower)) {
return { slug: "presentations", prefilledPrompt: prompt };
}
// Social
if (/\b(social|tweet|post|instagram|linkedin|twitter|x\.com|carousel)\b/.test(lower)) {
return { slug: "social", prefilledPrompt: prompt };

View file

@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
import { WORKSHOPS, findWorkshop, type WorkshopSlug } from "./workshops";
describe("WORKSHOPS data", () => {
it("defines exactly 8 workshops in the Phase 10 canonical order", () => {
it("defines exactly 9 workshops in the Phase 10 canonical order", () => {
// Presentations was added as a 9th workshop post-Wave-2 because
// PresentationPanel is a real Remotion-backed generator that was
// already in the codebase and is an Active v1.7 requirement per
// .planning/PROJECT.md. The initial spec called for 8 workshops;
// the layout spec was updated to match (see spec §6 revision note).
const slugs = WORKSHOPS.map((w) => w.slug);
expect(slugs).toEqual([
"diagrams",
@ -12,6 +17,7 @@ describe("WORKSHOPS data", () => {
"documents",
"brand-kits",
"social",
"presentations",
"convert",
] satisfies WorkshopSlug[]);
});

View file

@ -7,6 +7,7 @@ import {
Award,
Share2,
Repeat,
Presentation,
type LucideIcon,
} from "lucide-react";
@ -26,6 +27,7 @@ export type WorkshopSlug =
| "documents"
| "brand-kits"
| "social"
| "presentations"
| "convert";
export interface WorkshopDefinition {
@ -96,6 +98,13 @@ export const WORKSHOPS: WorkshopDefinition[] = [
icon: Share2,
componentKey: "social",
},
{
slug: "presentations",
title: "PRESENTATIONS",
subtitle: "Pitch decks and demo videos via Remotion",
icon: Presentation,
componentKey: "presentation",
},
{
slug: "convert",
title: "CONVERT",

View file

@ -6,15 +6,27 @@
// the project list is empty we render the 96px "NO PROJECTS YET"
// empty state canvas.
//
// Data gaps (Phase 11): the shared Project type has no
// milestoneProgress, nextGate, costBurned, lastActivity, or per-
// project agent counts. Each card renders "—" placeholders where
// data is missing. See the Phase 11 report for the full gap list.
// 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";
@ -26,6 +38,111 @@ 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
@ -51,11 +168,31 @@ export function Projects() {
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
@ -132,33 +269,41 @@ export function Projects() {
>
{projects.map((project) => {
const slug = (project.urlKey ?? project.name).toUpperCase();
const lastActivity = project.updatedAt
? (() => {
const d = typeof project.updatedAt === "string"
? new Date(project.updatedAt)
: project.updatedAt;
const ms = Date.now() - d.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`;
})()
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)}
// Data gap: no milestoneProgress on the shared Project type
// yet — ProjectCard renders "—%" when null.
progress={null}
// 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}
nextGateName={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={lastActivity}
lastActivity={formatRelative(latestActivity)}
ariaLabel={`Open project ${slug}`}
onClick={() => navigate(projectUrl(project))}
/>

View file

@ -11,6 +11,7 @@ import { SocialPostPanel } from "../components/SocialPostPanel";
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
import { BrandKitPanel } from "../components/BrandKitPanel";
import { ConvertPanel } from "../components/ConvertPanel";
import { PresentationPanel } from "../components/PresentationPanel";
import { ThemeSeedInput } from "../components/ThemeSeedInput";
import { ThemePaletteGrid, type PaletteRole } from "../components/ThemePaletteGrid";
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
@ -267,6 +268,8 @@ function WorkshopBody({
return <BrandKitPanel companyId={companyId} />;
case "social":
return <SocialPostPanel companyId={companyId} />;
case "presentation":
return <PresentationPanel companyId={companyId} />;
case "convert":
return <ConvertPanel companyId={companyId} />;
default: