mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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,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"];
|
||||
|
||||
@@ -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,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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user