feat: Workstation page — Products + Projects unified behind one sidebar entry (#549)

* feat(panel): Workstation page — Products and Projects as tabs behind one sidebar entry

* docs(map): workstation page, extracted views, and the local-filter scroll trade-off

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:45:21 +02:00
committed by GitHub
co-authored by Renn F
parent 496c24d186
commit c5dfbf6063
12 changed files with 386 additions and 275 deletions
+5 -12
View File
@@ -14,8 +14,7 @@ import {
Bot,
Shield,
BookOpen,
Boxes,
FolderGit2,
Briefcase,
GitBranch,
Database,
Cpu,
@@ -70,16 +69,10 @@ export const navItems = [
tip: "Branches, commits, and diffs across every project workspace",
},
{
title: "Projects",
href: "/projects",
icon: FolderGit2,
tip: "Manage repos, git tokens, and per-project settings",
},
{
title: "Products",
href: "/products",
icon: Boxes,
tip: "Products the fleet ships against",
title: "Workstation",
href: "/workstation",
icon: Briefcase,
tip: "Products the fleet ships against, and the repos/projects behind them",
},
{
title: "Social",
+1
View File
@@ -1,3 +1,4 @@
export { ProductTable } from "./product-table";
export { CreateProductDialog } from "./create-product-dialog";
export { EditProductDialog } from "./edit-product-dialog";
export { ProductsView } from "./products-view";
@@ -0,0 +1,59 @@
"use client";
import { useEffect } from "react";
import { useProducts } from "@/hooks/use-products";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProductDialog } from "@/components/products/create-product-dialog";
import { ProductTable } from "@/components/products/product-table";
import { usePageRefresh } from "@/hooks";
/** Products tab content — extracted from the standalone /products page so it
* can live inside the Workstation tab shell (see workstation/page.tsx). */
export function ProductsView() {
const { data: products, isLoading, error, refetch } = useProducts();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
Map each cell to the project it works on for a product
</p>
</div>
<div className="flex items-center gap-2">
<CreateProductDialog />
</div>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Products"
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
onRetry={() => void refresh()}
/>
) : (
<ProductTable products={products} isLoading={isLoading} />
)}
</div>
);
}
+1
View File
@@ -2,3 +2,4 @@ export { ProjectTable } from "./project-table";
export { ProjectFilters } from "./project-filters";
export { CreateProjectDialog } from "./create-project-dialog";
export { EditProjectDialog } from "./edit-project-dialog";
export { ProjectsView } from "./projects-view";
@@ -0,0 +1,116 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useProjects } from "@/hooks/use-projects";
import { Team } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProjectDialog } from "@/components/projects/create-project-dialog";
import { ProjectFilters } from "@/components/projects/project-filters";
import { ProjectTable } from "@/components/projects/project-table";
import { usePageRefresh } from "@/hooks";
/** Projects tab content — extracted from the standalone /projects page so it
* can live inside the Workstation tab shell (see workstation/page.tsx).
*
* Filter state is LOCAL, deliberately not URL params: every URL write forks
* ScrollRestoration's route key and force-scrolls <main> to top (the same
* bug class fixed in the work-sessions view — {scroll:false} does not
* prevent it). `tab` stays URL-owned by the workstation page shell; these
* filters don't ride the URL at all, so they reset on tab/page leave. */
export function ProjectsView() {
const [searchQuery, setSearchQuery] = useState("");
const [cellFilter, setCellFilter] = useState<Team[]>([]);
const [showInactive, setShowInactive] = useState(false);
// Fetch projects
const {
data: projects,
isLoading,
error,
refetch,
} = useProjects({
active_only: !showInactive,
});
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter projects client-side for search and multi-select cell filter
const filteredProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => {
// Search filter
if (
searchQuery &&
!project.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Cell filter (if any selected, project must match one of them)
if (
cellFilter.length > 0 &&
!cellFilter.includes(project.assigned_cell)
) {
return false;
}
return true;
});
}, [projects, searchQuery, cellFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage git repositories and track development work
</p>
</div>
<div className="flex items-center gap-2">
<CreateProjectDialog />
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<ProjectFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
cellFilter={cellFilter}
onCellChange={setCellFilter}
showInactive={showInactive}
onShowInactiveChange={setShowInactive}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Projects"
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => void refresh()}
/>
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
)}
</div>
);
}
@@ -560,7 +560,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<HelpTip label="Opens the Projects list (not a deep link to this specific project)">
<Link
prefetch={false}
href={`/projects`}
href={`/workstation?tab=projects`}
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{project.name}