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 (