mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -1,78 +1,7 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useProducts } from "@/hooks/use-products";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CreateProductDialog, ProductTable } from "@/components/products";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
|
||||
function ProductsPageContent() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense to match the dashboard page convention
|
||||
// Products merged into the Workstation tab shell — see
|
||||
// (dashboard)/workstation/page.tsx.
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProductsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
redirect("/workstation?tab=products");
|
||||
}
|
||||
|
||||
@@ -1,180 +1,7 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Suspense, useMemo, useCallback, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { Team } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import {
|
||||
CreateProjectDialog,
|
||||
ProjectFilters,
|
||||
ProjectTable,
|
||||
} from "@/components/projects";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
|
||||
function ProjectsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Read state from URL params
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const cellFilterParam = searchParams.get("cell");
|
||||
const cellFilter = useMemo(
|
||||
() => (cellFilterParam?.split(",").filter(Boolean) as Team[]) || [],
|
||||
[cellFilterParam],
|
||||
);
|
||||
const showInactive = searchParams.get("inactive") === "true";
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/projects?${query}` : "/projects");
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleCellChange = useCallback(
|
||||
(value: Team[]) => {
|
||||
updateParams({ cell: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleShowInactiveChange = useCallback(
|
||||
(value: boolean) => {
|
||||
updateParams({ inactive: value ? "true" : null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// 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={handleSearchChange}
|
||||
cellFilter={cellFilter}
|
||||
onCellChange={handleCellChange}
|
||||
showInactive={showInactive}
|
||||
onShowInactiveChange={handleShowInactiveChange}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
// Projects merged into the Workstation tab shell — see
|
||||
// (dashboard)/workstation/page.tsx.
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProjectsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
redirect("/workstation?tab=projects");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
// The two tab panes have their own dedicated tests — stub them here so this
|
||||
// page test only checks tab composition + the URL-driven default, mirroring
|
||||
// social/__tests__/page.test.tsx.
|
||||
vi.mock("@/components/products/products-view", () => ({
|
||||
ProductsView: () => <div>ProductsViewStub</div>,
|
||||
}));
|
||||
vi.mock("@/components/projects/projects-view", () => ({
|
||||
ProjectsView: () => <div>ProjectsViewStub</div>,
|
||||
}));
|
||||
|
||||
const mockReplace = vi.fn();
|
||||
let searchParams = new URLSearchParams();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
import WorkstationPage from "../page";
|
||||
|
||||
describe("WorkstationPage", () => {
|
||||
beforeEach(() => {
|
||||
searchParams = new URLSearchParams();
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it("defaults to the Products tab when the URL carries no ?tab", () => {
|
||||
render(<WorkstationPage />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Products" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"active",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Projects" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"inactive",
|
||||
);
|
||||
expect(screen.getByText("ProductsViewStub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("activates the Projects tab from ?tab=projects", () => {
|
||||
searchParams = new URLSearchParams("tab=projects");
|
||||
render(<WorkstationPage />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Projects" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"active",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Products" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"inactive",
|
||||
);
|
||||
expect(screen.getByText("ProjectsViewStub")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ProductsView } from "@/components/products/products-view";
|
||||
import { ProjectsView } from "@/components/projects/projects-view";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid tab values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TabDef {
|
||||
value: "products" | "projects";
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const TAB_DEFS: TabDef[] = [
|
||||
{
|
||||
value: "products",
|
||||
label: "Products",
|
||||
hint: "Products the fleet ships against",
|
||||
},
|
||||
{
|
||||
value: "projects",
|
||||
label: "Projects",
|
||||
hint: "Manage repos, git tokens, and per-project settings",
|
||||
},
|
||||
];
|
||||
|
||||
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isValidTab(value: string | null): value is TabValue {
|
||||
return TAB_VALUES.includes(value as TabValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner component that reads URL params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WorkstationPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "products";
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`/workstation?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
{TAB_DEFS.map((tab) => (
|
||||
<Tooltip key={tab.value}>
|
||||
<TooltipTrigger asChild>
|
||||
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
|
||||
own data-state; re-assert the real selection state
|
||||
explicitly (see task-detail/task-tabs.tsx) so the
|
||||
data-[state=active] styling still fires. */}
|
||||
<TabsTrigger
|
||||
value={tab.value}
|
||||
data-state={tab.value === activeTab ? "active" : "inactive"}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tab.hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="products" className="mt-4">
|
||||
<ProductsView />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projects" className="mt-4">
|
||||
<ProjectsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page export — wraps in Suspense for useSearchParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WorkstationPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-72" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<WorkstationPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user