feat: Workstation card grids — view toggle, sorting, agent-card visual language (#556)

* feat(panel): Workstation card grids with persisted view toggle and sorting

* fix(panel): stable multiplier sort with cell-label fallback after adversarial review

* docs(map): panel entries for this wave

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 07:04:38 +02:00
committed by GitHub
co-authored by Renn F
parent 4e12e65869
commit 02601fef22
15 changed files with 898 additions and 33 deletions
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProductCardGrid } from "../product-card-grid";
import { Team } from "@/types";
import type { ProductSummary } from "@/types";
const product: ProductSummary = {
id: "p1",
name: "RoboCo Platform",
slug: "roboco-platform",
cell_count: 3,
cells: [
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
],
progress: { done: 42, active: 5, blocked: 1 },
};
describe("ProductCardGrid", () => {
it("renders one card carrying the product's name, cells, and progress", () => {
render(<ProductCardGrid products={[product]} isLoading={false} />);
expect(screen.getByText("RoboCo Platform")).toBeInTheDocument();
expect(screen.getByText("roboco-platform")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
expect(screen.getAllByText("roboco").length).toBe(2);
expect(screen.getByText("42 done")).toBeInTheDocument();
expect(screen.getByText("1 blocked")).toBeInTheDocument();
});
it("shows the empty state when there are no products", () => {
render(<ProductCardGrid products={[]} isLoading={false} />);
expect(screen.getByText("No products found")).toBeInTheDocument();
});
it("shows the unmapped label for a product with no cells", () => {
const bare: ProductSummary = {
id: "p2",
name: "Bare Product",
slug: "bare",
cell_count: 0,
cells: [],
progress: { done: 0, active: 0, blocked: 0 },
};
render(<ProductCardGrid products={[bare]} isLoading={false} />);
expect(screen.getByText("Unmapped")).toBeInTheDocument();
});
it("renders loading skeletons and not the empty state while loading", () => {
render(<ProductCardGrid products={undefined} isLoading={true} />);
expect(screen.queryByText("No products found")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ProductSummary } from "@/types";
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
}));
const { useProducts } = vi.hoisted(() => ({ useProducts: vi.fn() }));
vi.mock("@/hooks/use-products", () => ({ useProducts }));
vi.mock("../create-product-dialog", () => ({
CreateProductDialog: () => null,
}));
vi.mock("../product-card-grid", () => ({
ProductCardGrid: ({ products }: { products?: ProductSummary[] }) => (
<div data-testid="card-grid">
{(products ?? []).map((p) => p.name).join(",")}
</div>
),
}));
vi.mock("../product-table", () => ({
ProductTable: ({ products }: { products?: ProductSummary[] }) => (
<div data-testid="table">
{(products ?? []).map((p) => p.name).join(",")}
</div>
),
}));
import { ProductsView, sortProducts } from "../products-view";
import { useUIStore } from "@/store/ui-store";
const PRODUCTS: ProductSummary[] = [
{
id: "p-zeta",
name: "Zeta",
slug: "zeta",
cell_count: 1,
cells: [],
progress: { done: 0, active: 0, blocked: 0 },
},
{
id: "p-alpha",
name: "Alpha",
slug: "alpha",
cell_count: 3,
cells: [],
progress: { done: 0, active: 0, blocked: 0 },
},
];
describe("ProductsView", () => {
beforeEach(() => {
useUIStore.setState({ productsView: "cards" });
useProducts.mockReturnValue({
data: PRODUCTS,
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
});
it("defaults to the card grid view", () => {
render(<ProductsView />);
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
});
it("switches to the table view and back via the toggle", async () => {
const user = userEvent.setup();
render(<ProductsView />);
await user.click(screen.getByLabelText("Table view"));
expect(screen.getByTestId("table")).toBeInTheDocument();
expect(screen.queryByTestId("card-grid")).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Card view"));
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
});
it("sorts cards by name ascending by default", () => {
render(<ProductsView />);
expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta");
});
it("flips to descending when the direction toggle is clicked", async () => {
const user = userEvent.setup();
render(<ProductsView />);
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getByTestId("card-grid")).toHaveTextContent("Zeta,Alpha");
});
});
describe("sortProducts (pure)", () => {
const prod = (name: string, cell_count: number) =>
({ name, cell_count }) as unknown as ProductSummary;
it("desc preserves the relative order of ties", () => {
const rows = [prod("A", 2), prod("B", 2), prod("C", 2)];
const out = sortProducts(rows, "cells", "desc");
expect(out.map((r) => r.name)).toEqual(["A", "B", "C"]);
});
});
+1
View File
@@ -1,4 +1,5 @@
export { ProductTable } from "./product-table";
export { ProductCardGrid } from "./product-card-grid";
export { CreateProductDialog } from "./create-product-dialog";
export { EditProductDialog } from "./edit-product-dialog";
export { ProductsView } from "./products-view";
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary } from "@/types";
import { EditProductDialog } from "./edit-product-dialog";
import { CellsList, ProgressCell } from "./product-table";
interface ProductCardGridProps {
products: ProductSummary[] | undefined;
isLoading: boolean;
}
// Same intrinsic-sizing grid as the Agents page card grid (agent-grid.tsx):
// one column on a phone, as many as fit at 17rem+ on a wide monitor.
const GRID_COLS = "grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]";
export function ProductCardGrid({ products, isLoading }: ProductCardGridProps) {
const [editingProductId, setEditingProductId] = useState<string | null>(null);
if (isLoading) {
return (
<div className={"grid gap-3 " + GRID_COLS}>
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="gap-2.5 py-4">
<CardHeader className="gap-1 px-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</CardHeader>
</Card>
))}
</div>
);
}
if (!products || products.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No products found</p>
<p className="text-sm">Create a product to map cells to projects</p>
</div>
);
}
return (
<>
<div className={"grid gap-3 " + GRID_COLS}>
{products.map((product) => (
<Card key={product.id} className="gap-2.5 py-4">
<CardHeader className="gap-1 px-4">
<div className="flex items-center justify-between gap-1">
<CardTitle className="min-w-0 truncate text-base">
<Button
onClick={() => setEditingProductId(product.id)}
variant="link"
className="h-auto max-w-full truncate p-0 font-semibold text-base text-foreground"
>
{product.name}
</Button>
</CardTitle>
<HelpTip label="Edit product name, description, and cell-project mapping">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setEditingProductId(product.id)}
aria-label="Edit product"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</HelpTip>
</div>
<HelpTip label="Identifier for this cell-to-project grouping, used to reference it across the panel and API.">
<p className="w-fit truncate text-xs text-muted-foreground font-mono">
{product.slug}
</p>
</HelpTip>
</CardHeader>
<CardContent className="px-4 space-y-2.5">
<CellsList cells={product.cells} />
<ProgressCell progress={product.progress} />
</CardContent>
</Card>
))}
</div>
{editingProductId && (
<EditProductDialog
productId={editingProductId}
open={!!editingProductId}
onOpenChange={(open) => {
if (!open) setEditingProductId(null);
}}
/>
)}
</>
);
}
@@ -23,7 +23,7 @@ import type { ProductSummary, Team } from "@/types";
import { EditProductDialog } from "./edit-product-dialog";
import { HelpTip } from "@/components/ui/help-tip";
const TEAM_LABELS: Record<Team, string> = {
export const TEAM_LABELS: Record<Team, string> = {
board: "Board",
main_pm: "Main PM",
backend: "Backend",
@@ -37,7 +37,7 @@ interface ProductTableProps {
isLoading: boolean;
}
function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
export function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
if (cells.length === 0) {
return <span className="text-muted-foreground text-sm">Unmapped</span>;
}
@@ -62,7 +62,7 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
);
}
function ProgressCell({
export function ProgressCell({
progress,
}: {
progress: ProductSummary["progress"];
+124 -2
View File
@@ -1,16 +1,58 @@
"use client";
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { useProducts } from "@/hooks/use-products";
import { useUIStore } from "@/store/ui-store";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProductDialog } from "@/components/products/create-product-dialog";
import { ProductCardGrid } from "@/components/products/product-card-grid";
import { ProductTable } from "@/components/products/product-table";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { HelpTip } from "@/components/ui/help-tip";
import { ArrowDown, ArrowUp, LayoutGrid, Table2 } from "lucide-react";
import { usePageRefresh } from "@/hooks";
import type { ProductSummary } from "@/types";
type ProductSortKey = "name" | "cells";
type SortDirection = "asc" | "desc";
const SORT_OPTIONS: { value: ProductSortKey; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "cells", label: "Cell count" },
];
// Exported for direct unit tests. Multiplier, not sort-then-reverse — see
// sortProjects in projects-view.tsx.
export function sortProducts(
products: ProductSummary[],
key: ProductSortKey,
direction: SortDirection,
): ProductSummary[] {
const dir = direction === "asc" ? 1 : -1;
return [...products].sort(
(a, b) =>
dir *
(key === "name"
? a.name.localeCompare(b.name)
: a.cell_count - b.cell_count),
);
}
/** 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 view = useUIStore((s) => s.productsView);
const setView = useUIStore((s) => s.setProductsView);
const [sortKey, setSortKey] = useState<ProductSortKey>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const { register, unregister, refresh } = usePageRefresh();
@@ -22,6 +64,13 @@ export function ProductsView() {
return () => unregister(cb);
}, [register, unregister, refetch]);
// Sorting is client-side over the already-loaded list — only relevant to
// the card view; the table keeps its own (unsorted, creation-order) render.
const sortedProducts = useMemo(
() => (products ? sortProducts(products, sortKey, sortDirection) : products),
[products, sortKey, sortDirection],
);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
@@ -32,7 +81,7 @@ export function ProductsView() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
@@ -40,6 +89,77 @@ export function ProductsView() {
</p>
</div>
<div className="flex items-center gap-2">
{view === "cards" && (
<>
<Select
value={sortKey}
onValueChange={(v) => setSortKey(v as ProductSortKey)}
>
<HelpTip label="Sort the product cards by this field">
<SelectTrigger size="sm" className="w-auto min-w-32">
<SelectValue />
</SelectTrigger>
</HelpTip>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpTip
label={
sortDirection === "asc"
? "Ascending — click for descending"
: "Descending — click for ascending"
}
>
<Button
variant="outline"
size="icon"
onClick={() =>
setSortDirection((d) => (d === "asc" ? "desc" : "asc"))
}
aria-label="Toggle sort direction"
>
{sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)}
</Button>
</HelpTip>
</>
)}
<div className="flex items-center gap-1 rounded-md border p-0.5">
<HelpTip label="Card view — boxes with badges, one per product">
<Button
type="button"
variant={view === "cards" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "cards"}
aria-label="Card view"
onClick={() => setView("cards")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</HelpTip>
<HelpTip label="Table view">
<Button
type="button"
variant={view === "table" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "table"}
aria-label="Table view"
onClick={() => setView("table")}
>
<Table2 className="h-3.5 w-3.5" />
</Button>
</HelpTip>
</div>
<CreateProductDialog />
</div>
</div>
@@ -51,6 +171,8 @@ export function ProductsView() {
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
onRetry={() => void refresh()}
/>
) : view === "cards" ? (
<ProductCardGrid products={sortedProducts} isLoading={isLoading} />
) : (
<ProductTable products={products} isLoading={isLoading} />
)}
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProjectCardGrid } from "../project-card-grid";
import { Team } from "@/types";
import type { ProjectSummary } from "@/types";
const project: ProjectSummary = {
id: "p1",
name: "RoboCo Core",
slug: "roboco",
git_url: "https://github.com/rennf93/roboco.git",
assigned_cell: Team.BACKEND,
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
ci_watch_enabled: true,
task_counts: { done: 42, active: 5, blocked: 1 },
};
describe("ProjectCardGrid", () => {
it("renders one card carrying the project's name, cell, tasks, token, status, and CI-watch badge", () => {
render(<ProjectCardGrid projects={[project]} isLoading={false} />);
expect(screen.getByText("RoboCo Core")).toBeInTheDocument();
expect(screen.getByText("roboco")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
expect(screen.getByText("42 done")).toBeInTheDocument();
expect(screen.getByText("1 blocked")).toBeInTheDocument();
expect(screen.getByText("Token Set")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("CI-Watch")).toBeInTheDocument();
});
it("shows the empty state when there are no projects", () => {
render(<ProjectCardGrid projects={[]} isLoading={false} />);
expect(screen.getByText("No projects found")).toBeInTheDocument();
});
it("renders an em-dash placeholder when task_counts is null and omits the CI-Watch badge", () => {
const bare: ProjectSummary = {
...project,
id: "p2",
name: "bare-project",
ci_watch_enabled: false,
task_counts: null,
};
render(<ProjectCardGrid projects={[bare]} isLoading={false} />);
expect(screen.getByText("bare-project")).toBeInTheDocument();
expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<ProjectCardGrid projects={undefined} isLoading={true} />);
expect(screen.queryByText("No projects found")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Team } from "@/types";
import type { ProjectSummary } from "@/types";
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
}));
const { useProjects } = vi.hoisted(() => ({ useProjects: vi.fn() }));
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
vi.mock("../create-project-dialog", () => ({
CreateProjectDialog: () => null,
}));
vi.mock("../project-card-grid", () => ({
ProjectCardGrid: ({ projects }: { projects?: ProjectSummary[] }) => (
<div data-testid="card-grid">
{(projects ?? []).map((p) => p.name).join(",")}
</div>
),
}));
vi.mock("../project-table", async () => {
const actual = await vi.importActual<typeof import("../project-table")>(
"../project-table",
);
return {
teamLabels: actual.teamLabels,
ProjectTable: ({ projects }: { projects?: ProjectSummary[] }) => (
<div data-testid="table">
{(projects ?? []).map((p) => p.name).join(",")}
</div>
),
};
});
import { ProjectsView, sortProjects } from "../projects-view";
import { useUIStore } from "@/store/ui-store";
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
id: overrides.id ?? "p1",
name: overrides.name ?? "Project",
slug: overrides.slug ?? "project",
git_url: "https://github.com/rennf93/roboco.git",
assigned_cell: Team.BACKEND,
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
ci_watch_enabled: false,
task_counts: null,
...overrides,
};
}
const PROJECTS: ProjectSummary[] = [
makeProject({ id: "p-zeta", name: "Zeta", assigned_cell: Team.FRONTEND }),
makeProject({ id: "p-alpha", name: "Alpha", assigned_cell: Team.BACKEND }),
];
describe("ProjectsView", () => {
beforeEach(() => {
useUIStore.setState({ projectsView: "cards" });
useProjects.mockReturnValue({
data: PROJECTS,
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
});
it("defaults to the card grid view", () => {
render(<ProjectsView />);
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
});
it("switches to the table view and back via the toggle", async () => {
const user = userEvent.setup();
render(<ProjectsView />);
await user.click(screen.getByLabelText("Table view"));
expect(screen.getByTestId("table")).toBeInTheDocument();
expect(screen.queryByTestId("card-grid")).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Card view"));
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
});
it("sorts cards by name ascending by default", () => {
render(<ProjectsView />);
expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta");
});
it("flips to descending when the direction toggle is clicked", async () => {
const user = userEvent.setup();
render(<ProjectsView />);
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getByTestId("card-grid")).toHaveTextContent("Zeta,Alpha");
});
});
describe("sortProjects (pure)", () => {
const proj = (name: string, cell: string) =>
({ name, assigned_cell: cell }) as unknown as ProjectSummary;
it("does not crash on a backend-only cell value and sorts it by raw value", () => {
const rows = [proj("a", "system"), proj("b", "backend")];
const out = sortProjects(rows, "cell", "asc");
// "Backend" < "system" (localeCompare, case-aware) — the point is no throw.
expect(out).toHaveLength(2);
});
it("desc preserves the relative order of ties (stable, not reversed)", () => {
const rows = [proj("A", "backend"), proj("B", "backend"), proj("C", "backend")];
const out = sortProjects(rows, "cell", "desc");
expect(out.map((r) => r.name)).toEqual(["A", "B", "C"]);
});
});
+1
View File
@@ -1,4 +1,5 @@
export { ProjectTable } from "./project-table";
export { ProjectCardGrid } from "./project-card-grid";
export { ProjectFilters } from "./project-filters";
export { CreateProjectDialog } from "./create-project-dialog";
export { EditProjectDialog } from "./edit-project-dialog";
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ExternalLink, GitBranch, Pencil } from "lucide-react";
import type { ProjectSummary } from "@/types";
import { EditProjectDialog } from "./edit-project-dialog";
import {
CiWatchBadge,
TasksCell,
getExternalUrl,
getStatusBadge,
getTokenBadge,
teamColors,
teamLabels,
} from "./project-table";
interface ProjectCardGridProps {
projects: ProjectSummary[] | undefined;
isLoading: boolean;
}
// Same intrinsic-sizing grid as the Agents page card grid (agent-grid.tsx):
// one column on a phone, as many as fit at 17rem+ on a wide monitor.
const GRID_COLS = "grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]";
export function ProjectCardGrid({ projects, isLoading }: ProjectCardGridProps) {
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
if (isLoading) {
return (
<div className={"grid gap-3 " + GRID_COLS}>
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="gap-2.5 py-4">
<CardHeader className="gap-1 px-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</CardHeader>
</Card>
))}
</div>
);
}
if (!projects || projects.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<GitBranch className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No projects found</p>
<p className="text-sm">
Create a project to get started with git integration
</p>
</div>
);
}
return (
<>
<div className={"grid gap-3 " + GRID_COLS}>
{projects.map((project) => (
<Card key={project.id} className="gap-2.5 py-4">
<CardHeader className="gap-1 px-4">
<div className="flex items-center justify-between gap-1">
<CardTitle className="min-w-0 truncate text-base">
<Button
onClick={() => setEditingProjectId(project.id)}
variant="link"
className="h-auto max-w-full truncate p-0 font-semibold text-base text-foreground"
>
{project.name}
</Button>
</CardTitle>
<div className="flex shrink-0 items-center gap-0.5">
<HelpTip label="Edit project settings and CI/CD commands">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingProjectId(project.id)}
aria-label="Edit project"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</HelpTip>
<HelpTip label="Open the git repository in a new tab">
<Button variant="ghost" size="icon" className="h-6 w-6" asChild>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
aria-label="View repository"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</HelpTip>
</div>
</div>
<HelpTip label="Composes each agent's workspace clone path and every branch name for this project.">
<p className="w-fit truncate text-xs text-muted-foreground font-mono">
{project.slug}
</p>
</HelpTip>
</CardHeader>
<CardContent className="px-4 space-y-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<HelpTip label="Only this cell's agents can claim tasks on this project.">
<Badge className={teamColors[project.assigned_cell]}>
{teamLabels[project.assigned_cell]}
</Badge>
</HelpTip>
{getStatusBadge(project.is_active)}
{getTokenBadge(project.has_git_token)}
{project.ci_watch_enabled && <CiWatchBadge enabled />}
</div>
<TasksCell counts={project.task_counts} />
</CardContent>
</Card>
))}
</div>
{editingProjectId && (
<EditProjectDialog
projectId={editingProjectId}
open={!!editingProjectId}
onOpenChange={(open) => {
if (!open) setEditingProjectId(null);
}}
/>
)}
</>
);
}
+20 -20
View File
@@ -28,7 +28,7 @@ interface ProjectTableProps {
isLoading: boolean;
}
const teamLabels: Record<Team, string> = {
export const teamLabels: Record<Team, string> = {
board: "Board",
main_pm: "Main PM",
backend: "Backend",
@@ -37,7 +37,7 @@ const teamLabels: Record<Team, string> = {
marketing: "Marketing",
};
const teamColors: Record<Team, string> = {
export const teamColors: Record<Team, string> = {
board: "bg-purple-500/10 text-purple-500 hover:bg-purple-500/20",
main_pm: "bg-blue-500/10 text-blue-500 hover:bg-blue-500/20",
backend: "bg-green-500/10 text-green-500 hover:bg-green-500/20",
@@ -46,7 +46,7 @@ const teamColors: Record<Team, string> = {
marketing: "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20",
};
function getTokenBadge(hasGitToken: boolean) {
export function getTokenBadge(hasGitToken: boolean) {
const badge = hasGitToken ? (
<Badge className="bg-green-500/10 text-green-500">
<Key className="h-3 w-3 mr-1" />
@@ -65,7 +65,7 @@ function getTokenBadge(hasGitToken: boolean) {
);
}
function getStatusBadge(isActive: boolean) {
export function getStatusBadge(isActive: boolean) {
const badge = isActive ? (
<Badge className="bg-green-500/10 text-green-500">Active</Badge>
) : (
@@ -79,7 +79,7 @@ function getStatusBadge(isActive: boolean) {
return <HelpTip label={hint}>{badge}</HelpTip>;
}
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
export function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
if (!counts) {
return <span className="text-muted-foreground text-xs"></span>;
}
@@ -118,7 +118,7 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
);
}
function CiWatchBadge({ enabled }: { enabled: boolean }) {
export function CiWatchBadge({ enabled }: { enabled: boolean }) {
if (!enabled) return null;
return (
<HelpTip label="Opens a fix task automatically when this project's CI goes red on its default branch.">
@@ -133,6 +133,20 @@ function CiWatchBadge({ enabled }: { enabled: boolean }) {
);
}
// Converts a git URL to a browsable HTTPS URL (strip .git suffix, handle SSH
// format). Module-level (not component-local) so the card grid view can
// reuse it verbatim for the same "View repository" action.
export function getExternalUrl(project: Pick<ProjectSummary, "git_url">) {
let url = project.git_url;
if (url.endsWith(".git")) {
url = url.slice(0, -4);
}
if (url.startsWith("git@")) {
url = url.replace("git@", "https://").replace(":", "/");
}
return url;
}
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
@@ -158,20 +172,6 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
);
}
// Convert git URL to browsable URL (strip .git suffix, handle SSH format)
const getExternalUrl = (project: ProjectSummary) => {
let url = project.git_url;
// Remove .git suffix
if (url.endsWith(".git")) {
url = url.slice(0, -4);
}
// Convert SSH format (git@github.com:org/repo) to HTTPS
if (url.startsWith("git@")) {
url = url.replace("git@", "https://").replace(":", "/");
}
return url;
};
return (
<>
<ResponsiveTable
+129 -2
View File
@@ -2,13 +2,56 @@
import { useMemo, useState, useEffect } from "react";
import { useProjects } from "@/hooks/use-projects";
import { useUIStore } from "@/store/ui-store";
import { Team } from "@/types";
import type { ProjectSummary } 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 { ProjectCardGrid } from "@/components/projects/project-card-grid";
import { ProjectTable, teamLabels } from "@/components/projects/project-table";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { HelpTip } from "@/components/ui/help-tip";
import { ArrowDown, ArrowUp, LayoutGrid, Table2 } from "lucide-react";
import { usePageRefresh } from "@/hooks";
type ProjectSortKey = "name" | "cell";
type SortDirection = "asc" | "desc";
const SORT_OPTIONS: { value: ProjectSortKey; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "cell", label: "Cell" },
];
// Exported for direct unit tests. Direction rides a comparator multiplier
// (task-table.tsx's pattern), NOT sort-then-reverse — reversing also flips
// the relative order of ties. The label fallback matters: the backend Team
// enum is a superset of the panel's (fullstack/system), and an out-of-map
// cell must sort by its raw value, never crash the view.
export function sortProjects(
projects: ProjectSummary[],
key: ProjectSortKey,
direction: SortDirection,
): ProjectSummary[] {
const dir = direction === "asc" ? 1 : -1;
const cellLabel = (cell: ProjectSummary["assigned_cell"]) =>
teamLabels[cell] ?? String(cell);
return [...projects].sort(
(a, b) =>
dir *
(key === "name"
? a.name.localeCompare(b.name)
: cellLabel(a.assigned_cell).localeCompare(cellLabel(b.assigned_cell))),
);
}
/** Projects tab content extracted from the standalone /projects page so it
* can live inside the Workstation tab shell (see workstation/page.tsx).
*
@@ -21,6 +64,10 @@ export function ProjectsView() {
const [searchQuery, setSearchQuery] = useState("");
const [cellFilter, setCellFilter] = useState<Team[]>([]);
const [showInactive, setShowInactive] = useState(false);
const view = useUIStore((s) => s.projectsView);
const setView = useUIStore((s) => s.setProjectsView);
const [sortKey, setSortKey] = useState<ProjectSortKey>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
// Fetch projects
const {
@@ -67,6 +114,13 @@ export function ProjectsView() {
});
}, [projects, searchQuery, cellFilter]);
// Sorting is client-side over the filtered list — only relevant to the
// card view; the table keeps its own (unsorted, filter-order) render.
const sortedProjects = useMemo(
() => sortProjects(filteredProjects, sortKey, sortDirection),
[filteredProjects, sortKey, sortDirection],
);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
@@ -77,7 +131,7 @@ export function ProjectsView() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
@@ -85,6 +139,77 @@ export function ProjectsView() {
</p>
</div>
<div className="flex items-center gap-2">
{view === "cards" && (
<>
<Select
value={sortKey}
onValueChange={(v) => setSortKey(v as ProjectSortKey)}
>
<HelpTip label="Sort the project cards by this field">
<SelectTrigger size="sm" className="w-auto min-w-32">
<SelectValue />
</SelectTrigger>
</HelpTip>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpTip
label={
sortDirection === "asc"
? "Ascending — click for descending"
: "Descending — click for ascending"
}
>
<Button
variant="outline"
size="icon"
onClick={() =>
setSortDirection((d) => (d === "asc" ? "desc" : "asc"))
}
aria-label="Toggle sort direction"
>
{sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)}
</Button>
</HelpTip>
</>
)}
<div className="flex items-center gap-1 rounded-md border p-0.5">
<HelpTip label="Card view — boxes with badges, one per project">
<Button
type="button"
variant={view === "cards" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "cards"}
aria-label="Card view"
onClick={() => setView("cards")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</HelpTip>
<HelpTip label="Table view">
<Button
type="button"
variant={view === "table" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "table"}
aria-label="Table view"
onClick={() => setView("table")}
>
<Table2 className="h-3.5 w-3.5" />
</Button>
</HelpTip>
</div>
<CreateProjectDialog />
</div>
</div>
@@ -108,6 +233,8 @@ export function ProjectsView() {
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => void refresh()}
/>
) : view === "cards" ? (
<ProjectCardGrid projects={sortedProjects} isLoading={isLoading} />
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
)}
+13
View File
@@ -17,6 +17,11 @@ interface UIState {
// design doc §1) — same persisted-preference idiom as sidebar/theme.
a2aContextOpen: boolean;
// Workstation cards/table toggle, persisted per-surface so Products and
// Projects remember their own choice independently. Cards is the default.
productsView: "cards" | "table";
projectsView: "cards" | "table";
// Client-only Settings-page prefs (never sent to the backend — the
// server's settings allowlist is transcript_retention_days + feature
// flags only). Same persisted-preference idiom as sidebar/theme.
@@ -31,6 +36,8 @@ interface UIState {
setTheme: (theme: "light" | "dark" | "system") => void;
setCurrentTeam: (team: Team | null) => void;
toggleA2AContext: () => void;
setProductsView: (view: "cards" | "table") => void;
setProjectsView: (view: "cards" | "table") => void;
setNotificationsEnabled: (enabled: boolean) => void;
setSoundEnabled: (enabled: boolean) => void;
setAutoRefresh: (enabled: boolean) => void;
@@ -45,6 +52,8 @@ export const useUIStore = create<UIState>()(
theme: "system",
currentTeam: null,
a2aContextOpen: true,
productsView: "cards",
projectsView: "cards",
notificationsEnabled: true,
soundEnabled: true,
autoRefresh: false, // default-off: never start a background poller unasked
@@ -57,6 +66,8 @@ export const useUIStore = create<UIState>()(
setCurrentTeam: (team) => set({ currentTeam: team }),
toggleA2AContext: () =>
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
setProductsView: (view) => set({ productsView: view }),
setProjectsView: (view) => set({ projectsView: view }),
setNotificationsEnabled: (enabled) =>
set({ notificationsEnabled: enabled }),
setSoundEnabled: (enabled) => set({ soundEnabled: enabled }),
@@ -71,6 +82,8 @@ export const useUIStore = create<UIState>()(
theme: state.theme,
currentTeam: state.currentTeam,
a2aContextOpen: state.a2aContextOpen,
productsView: state.productsView,
projectsView: state.projectsView,
notificationsEnabled: state.notificationsEnabled,
soundEnabled: state.soundEnabled,
autoRefresh: state.autoRefresh,