From a18d69d17b3a930443ba0d613166013f8b01b7aa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 22 May 2026 14:04:03 +0000 Subject: [PATCH] feat(ui): global project tab nav on every /projects/[id]/* route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old behavior: the layout-client only rendered the project header + tab nav for autoblog/social paths. Other tabs (overview, scans, performance, stats, repos, getting-started) rendered their own ProjectShell. Pages not covered by either branch — most notably /projects/[id]/runs/[runId], and any deep autoblog/social sub-routes that hadn't been wrapped — got no nav at all and felt like dead ends. Fix: hoist the header + status controls + tab nav into the project layout (layout-client.tsx) unconditionally. ProjectTabsNav already derives currentTab from usePathname() so the layout doesn't need to know the active tab. Refactor: - layout-client.tsx: drop the isSubTab gate; always render the chrome. - components/project-tabs-nav.tsx: rewrite as the single source of truth for the tabs list + per-tab matchers (Scans tab also lights up on /runs/[runId]; Overview matches the bare project URL only). - components/project-shell.tsx: now a pass-through. Six existing pages still call {children}; making it a noop avoids touching every page in this PR. The original render is deprecated and a follow-up can drop the wrappers entirely. After this PR every project sub-route shows the same chrome at the same Y, including run-detail and any future deep links. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/projects/[id]/layout-client.tsx | 50 +++++------- components/project-shell.tsx | 95 +++-------------------- components/project-tabs-nav.tsx | 59 ++++++++++---- 3 files changed, 76 insertions(+), 128 deletions(-) diff --git a/app/(app)/projects/[id]/layout-client.tsx b/app/(app)/projects/[id]/layout-client.tsx index f0ea27c4..683b865f 100644 --- a/app/(app)/projects/[id]/layout-client.tsx +++ b/app/(app)/projects/[id]/layout-client.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; import { ProjectTabsNav } from "@/components/project-tabs-nav"; import { ProjectStatusControls } from "@/components/project-status-controls"; import { ScheduleToggle } from "@/components/schedule-toggle"; @@ -10,11 +9,10 @@ import { ProjectHeaderLogo } from "@/components/project-header-logo"; import type { ProjectStatus } from "@/app/actions/projects"; import type { Engine } from "@/lib/credits"; -// On the autoblog/social tabs the layout owns the header chrome. -// Mirrors ProjectShell exactly (logo + name + action bar + tabs) so the -// tabs nav stays at the same Y across all project tabs — otherwise it -// jumps when switching e.g. /scans → /autoblog because the action bar -// height changes. +// Owns the project-level chrome (breadcrumb + logo/title + action bar + +// tab nav) on EVERY /projects/[id]/* route, so the nav is always there +// and never shifts position between tabs. Tab pages render their inner +// content into `children`. export function ProjectLayoutClient({ projectId, name, @@ -34,33 +32,27 @@ export function ProjectLayoutClient({ engines: Engine[]; children: React.ReactNode; }) { - const pathname = usePathname() ?? ""; - const isSubTab = /\/projects\/[^/]+\/(autoblog|social)(\/|$)/.test(pathname); - return (
- {isSubTab && ( - <> -
- - ← Dashboard - -
- -

{name}

-
-

{url}

-
+
+ + ← Dashboard + +
+ +

{name}

+
+

{url}

+
+ +
+ + + +
-
- - - -
+ - - - )}
{children}
); diff --git a/components/project-shell.tsx b/components/project-shell.tsx index 76e95bbb..a2d78442 100644 --- a/components/project-shell.tsx +++ b/components/project-shell.tsx @@ -1,8 +1,10 @@ -import Link from "next/link"; -import { ScheduleToggle } from "@/components/schedule-toggle"; -import { ProjectStatusControls } from "@/components/project-status-controls"; -import { RunNowButton } from "@/components/run-now-button"; -import { ProjectHeaderLogo } from "@/components/project-header-logo"; +// Deprecated: the project chrome (breadcrumb, header, status controls, +// tab nav) now lives in /projects/[id]/layout.tsx + layout-client.tsx, +// so every project page picks it up automatically. ProjectShell is kept +// as a pass-through for compatibility with the six pages that still +// render {content}. Removing those wrappers +// in a follow-up PR is fine; this file is intentionally trivial. + import type { ProjectStatus } from "@/app/actions/projects"; import type { Engine } from "@/lib/credits"; @@ -26,30 +28,6 @@ type ProjectLite = { logo_url?: string | null; }; -const TABS: { id: ProjectShellTab; label: string; href: (id: string) => string }[] = [ - { - id: "getting-started", - label: "Getting Started", - href: (id) => `/projects/${id}/getting-started`, - }, - { id: "overview", label: "Overview", href: (id) => `/projects/${id}` }, - { - id: "performance", - label: "Performance", - href: (id) => `/projects/${id}/performance`, - }, - { id: "scans", label: "Scans", href: (id) => `/projects/${id}/scans` }, - { id: "stats", label: "Stats", href: (id) => `/projects/${id}/stats` }, - { id: "autoblog", label: "Autoblog", href: (id) => `/projects/${id}/autoblog` }, - { id: "social", label: "Social", href: (id) => `/projects/${id}/social` }, - { id: "repos", label: "Repos", href: (id) => `/projects/${id}/repos` }, -]; - -// Shared shell for the three project tab pages. Renders the breadcrumb, -// project header, action bar (status / schedule / run-now), and tab nav. -// Each tab page wraps its own content with this so we don't need a -// layout file (the run-detail page hangs off the same /projects/[id] -// route and shouldn't show the tab nav). export function ProjectShell({ project, currentTab, @@ -59,58 +37,9 @@ export function ProjectShell({ currentTab: ProjectShellTab; children: React.ReactNode; }) { - const engines: Engine[] = project.engines ?? ["rule"]; - return ( -
-
- - ← Dashboard - -
- -

{project.name}

-
-

{project.url}

-
- -
- - - -
- - - -
{children}
-
- ); + // Both props are intentionally unused — the layout reads the project + // and the tab nav resolves currentTab from the URL. + void project; + void currentTab; + return <>{children}; } diff --git a/components/project-tabs-nav.tsx b/components/project-tabs-nav.tsx index 1a0b8c18..e4b554ee 100644 --- a/components/project-tabs-nav.tsx +++ b/components/project-tabs-nav.tsx @@ -2,62 +2,89 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import type { ProjectShellTab } from "./project-shell"; -const TABS: { id: ProjectShellTab; label: string; href: (id: string) => string; match: RegExp }[] = [ +interface ProjectTab { + id: string; + label: string; + href: (id: string) => string; + /** Returns true when the pathname should highlight this tab. */ + matches: (pathname: string, projectId: string) => boolean; +} + +// Order = visual order in the nav. +const TABS: ProjectTab[] = [ + { + id: "getting-started", + label: "Getting Started", + href: (id) => `/projects/${id}/getting-started`, + matches: (p, id) => p.startsWith(`/projects/${id}/getting-started`), + }, { id: "overview", label: "Overview", href: (id) => `/projects/${id}`, - match: /^\/projects\/[^/]+\/?$/, + // Exact match — every other tab is a sub-route, but Overview is the bare project URL. + matches: (p, id) => p === `/projects/${id}` || p === `/projects/${id}/`, }, { id: "performance", label: "Performance", href: (id) => `/projects/${id}/performance`, - match: /^\/projects\/[^/]+\/performance(\/|$)/, + matches: (p, id) => p.startsWith(`/projects/${id}/performance`), }, { id: "scans", label: "Scans", href: (id) => `/projects/${id}/scans`, - match: /^\/projects\/[^/]+\/scans(\/|$)/, + matches: (p, id) => + p.startsWith(`/projects/${id}/scans`) || + // Run-detail pages live under /projects/[id]/runs/[runId] but + // visually belong with the Scans tab. + p.startsWith(`/projects/${id}/runs`), + }, + { + id: "stats", + label: "Stats", + href: (id) => `/projects/${id}/stats`, + matches: (p, id) => p.startsWith(`/projects/${id}/stats`), }, { id: "autoblog", label: "Autoblog", href: (id) => `/projects/${id}/autoblog`, - match: /^\/projects\/[^/]+\/autoblog(\/|$)/, + matches: (p, id) => p.startsWith(`/projects/${id}/autoblog`), }, { id: "social", label: "Social", href: (id) => `/projects/${id}/social`, - match: /^\/projects\/[^/]+\/social(\/|$)/, + matches: (p, id) => p.startsWith(`/projects/${id}/social`), + }, + { + id: "repos", + label: "Repos", + href: (id) => `/projects/${id}/repos`, + matches: (p, id) => p.startsWith(`/projects/${id}/repos`), }, ]; -// Resolves the active tab from the URL so the layout doesn't need to -// pass it down. Returns null on /projects/[id]/runs/[runId] where no -// tab should appear selected. export function ProjectTabsNav({ projectId }: { projectId: string }) { - const pathname = usePathname(); - const active = TABS.find((t) => t.match.test(pathname ?? "")); + const pathname = usePathname() ?? ""; return (