Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 164 additions & 78 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ import { SkeletonList } from "./components/SkeletonCard";
import { EmptyState } from "./components/EmptyState";
import { StateFilter } from "./components/StateFilter";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BoardTable } from "./components/BoardTable";
import { BoardPreviewRail } from "./components/BoardPreviewRail";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { LucideIcon } from "lucide-react";
import {
Search,
LayoutGrid,
Rows3,
Table2,
FileText,
Eye,
Rocket,
Expand All @@ -44,8 +45,11 @@ import { buildBoardItems } from "./lib/stack-tree";
import { isFastLane } from "./lib/attention";
import { collectFleetReviews } from "./lib/fleet";
import { agentActionLine } from "./lib/agent-event";
import type { CardDensity } from "./components/ReviewCard";
import { useMediaQuery } from "./hooks/useMediaQuery";
import type { BoardDensity } from "./lib/board-density";
import { loadDensity, saveDensity } from "./lib/board-density";
import type { GateState } from "./bindings/GateState";
import type { DiffSide } from "./bindings/DiffSide";
import type { Review } from "./bindings/Review";
import type { Project } from "./bindings/Project";
import type { AgentMode } from "./bindings/AgentMode";
Expand Down Expand Up @@ -83,14 +87,13 @@ interface AgentEventEnvelope {
type ReviewTab = "my-prs" | "review-requests" | "all";

const SIDEBAR_COLLAPSED_KEY = "cockpit-sidebar-collapsed";
const PRS_DENSITY_KEY = "cockpit-prs-density";

/** Read the persisted board density, defaulting to the roomy card layout. */
function loadDensity(): CardDensity {
return localStorage.getItem(PRS_DENSITY_KEY) === "compact"
? "compact"
: "cards";
}
/**
* Minimum window width (px) at which the board has room for the preview rail.
* Below this the rail is not rendered; selection still works and Enter /
* double-click opens the review directly.
*/
const RAIL_MIN_WIDTH_QUERY = "(min-width: 1280px)";

function assertNever(x: never): never {
throw new Error(`unreachable: ${String(x)}`);
Expand Down Expand Up @@ -276,13 +279,19 @@ function App() {
// the same pending request never double-notifies (one notification per id).
const notifiedPermissionIds = useRef<Set<string>>(new Set<string>());

const [prsDensity, setPrsDensity] = useState<CardDensity>(loadDensity);
const [density, setBoardDensity] = useState<BoardDensity>(loadDensity);

const setDensity = useCallback((density: CardDensity) => {
setPrsDensity(density);
localStorage.setItem(PRS_DENSITY_KEY, density);
const setDensity = useCallback((next: BoardDensity) => {
setBoardDensity(next);
saveDensity(next);
}, []);

// Triage-table selection (drives the preview rail), by PR ref.
const [selectedPr, setSelectedPr] = useState<string | null>(null);
const requestDiffJump = useAppStore((s) => s.requestDiffJump);
// Only render the preview rail when the window is wide enough for it.
const railFits = useMediaQuery(RAIL_MIN_WIDTH_QUERY);

const toggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => {
const next = !prev;
Expand Down Expand Up @@ -630,6 +639,29 @@ function App() {
[restackPr],
);

// Open a review's workspace directly (row double-click / Enter, rail footer).
// The Briefing-vs-Diff landing decision lives in the workspace itself.
const handleOpenReview = useCallback(
(pr: string) => {
void navigateToDiff(pr);
},
[navigateToDiff],
);

const clearSelection = useCallback(() => {
setSelectedPr(null);
}, []);

// Reveal a rail finding in the review's Diff tab: publish the jump, then open
// the review (DiffView consumes the pending jump for its own PR).
const handleRailJump = useCallback(
(pr: string, path: string, side: DiffSide, line: number) => {
requestDiffJump(pr, path, side, line);
void navigateToDiff(pr);
},
[requestDiffJump, navigateToDiff],
);

// Opening a project always routes to its plan gate. When the project has no
// plan yet, PlanView surfaces a "Generate plan" affordance.
const handleOpenProject = useCallback(
Expand Down Expand Up @@ -696,6 +728,7 @@ function App() {
setStateFilter(null);
setShowStale(false);
setSearchQuery("");
setSelectedPr(null);
if (tab === "my-prs") void fetchAuthoredPrs();
if (tab === "review-requests") void fetchReviewRequests();
if (tab === "all") void fetchReviews();
Expand Down Expand Up @@ -731,13 +764,15 @@ function App() {
({group.reviews.length})
</span>
</h2>
<div className={prsDensity === "compact" ? "space-y-1.5" : "space-y-3"}>
{/* Cards flow into a responsive multi-column grid so wide windows carry
several columns instead of one centered stripe. */}
<div className="grid items-start gap-3 [grid-template-columns:repeat(auto-fill,minmax(420px,1fr))]">
{buildBoardItems(group.reviews).map((item) =>
item.kind === "single" ? (
<ReviewCard
key={item.review.id}
review={item.review}
density={prsDensity}
density="cards"
onAction={handleReviewAction}
onRestack={handleRestack}
/>
Expand All @@ -746,7 +781,7 @@ function App() {
key={item.root.review.id}
root={item.root}
nodes={item.nodes}
density={prsDensity}
density="cards"
onAction={handleReviewAction}
onRestack={handleRestack}
/>
Expand All @@ -761,7 +796,7 @@ function App() {
{fastLane.length > 0 && (
<FastLaneShelf
reviews={fastLane}
density={prsDensity}
density="cards"
onAction={handleReviewAction}
onRestack={handleRestack}
/>
Expand All @@ -771,32 +806,114 @@ function App() {
);
}

function renderPrsContent(items: readonly Review[], emptyIcon: LucideIcon, emptyTitle: string, emptyDescription: string) {
if (prFetchLoading && items.length === 0) {
return <SkeletonList count={4} />;
/** The empty-state card for the active tab. */
function renderEmpty() {
switch (reviewTab) {
case "my-prs":
return (
<EmptyState
icon={FileText}
title="No open PRs"
description="Click Refresh to fetch your open PRs from GitHub. Make sure your repo path is configured in Settings."
/>
);
case "review-requests":
return (
<EmptyState
icon={Eye}
title="No review requests"
description="No PRs are waiting for your review. Click Refresh to check again."
/>
);
case "all":
return (
<EmptyState
icon={Rocket}
title="No reviews yet"
description="Create a project or import from Linear under Projects, or switch to Mine to review existing GitHub PRs."
actionLabel="Go to Projects"
onAction={navigateToProjects}
/>
);
default:
return assertNever(reviewTab);
}
const grouped = renderProjectGroupedList(items);
if (grouped === null) {
}

/**
* The board body for the active tab, in the current density. The `table`
* density is a flat, keyboard-navigable triage list paired with a preview
* rail (wide windows only); the `cards` density keeps the fast-lane shelf and
* project-grouped responsive card grid.
*/
function renderBoardBody() {
const items = reviewsForTab;
const loadingNow = reviewTab === "all" ? loading : prFetchLoading;

if (loadingNow && items.length === 0) {
return (
<EmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
/>
<div className="min-w-0 flex-1 overflow-y-auto px-6 py-6">
<SkeletonList count={4} />
</div>
);
}

if (density === "table") {
const filtered = filterReviews(items);
const selectedReview =
filtered.find((r) => r.pr === selectedPr) ?? null;
return (
<>
<div className="min-w-0 flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-6 py-6">{renderEmpty()}</div>
) : (
<BoardTable
reviews={filtered}
selectedPr={selectedReview?.pr ?? null}
onSelect={setSelectedPr}
onOpen={handleOpenReview}
onAction={handleReviewAction}
onClearSelection={clearSelection}
/>
)}
</div>
{railFits && selectedReview !== null && (
<aside className="w-[380px] shrink-0 overflow-y-auto border-l border-border bg-card">
<BoardPreviewRail
review={selectedReview}
onOpen={handleOpenReview}
onJumpTo={handleRailJump}
/>
</aside>
)}
</>
);
}
return grouped;

// Cards density: fast-lane shelf + project-grouped responsive grid.
const grouped = renderProjectGroupedList(items);
return (
<div className="min-w-0 flex-1 overflow-y-auto px-6 py-6">
{grouped ?? renderEmpty()}
</div>
);
}

function renderContent() {
switch (view.kind) {
case "prs":
return (
<div className="mx-auto max-w-4xl px-6 py-8">
{errorBanner}

<Tabs value={reviewTab} onValueChange={handleTabChange}>
<div className="flex items-center mb-6">
<div className="flex h-full min-h-0 flex-col">
{/* Header: tabs + refresh, then search + filters + density. Fixed at
the top so the board fills the rest of the window full-width. */}
<Tabs
value={reviewTab}
onValueChange={handleTabChange}
className="shrink-0 gap-3 border-b border-border px-6 pb-4 pt-6"
>
{errorBanner}
<div className="flex items-center">
<TabsList variant="line">
<TabsTrigger value="my-prs">
Mine
Expand Down Expand Up @@ -841,7 +958,7 @@ function App() {
</div>
</div>

<div className="mb-4 flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-52">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
Expand All @@ -865,7 +982,7 @@ function App() {
}}
/>

{/* Density toggle: roomy cards vs. dense telemetry rows. */}
{/* Density toggle: roomy card grid vs. dense triage table. */}
<div
className="ml-auto flex shrink-0 items-center rounded-lg border border-border p-0.5"
role="group"
Expand All @@ -876,11 +993,11 @@ function App() {
onClick={() => {
setDensity("cards");
}}
aria-pressed={prsDensity === "cards"}
aria-pressed={density === "cards"}
title="Card view"
className={cn(
"rounded-md p-1.5 transition-colors",
prsDensity === "cards"
density === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
Expand All @@ -890,56 +1007,25 @@ function App() {
<button
type="button"
onClick={() => {
setDensity("compact");
setDensity("table");
}}
aria-pressed={prsDensity === "compact"}
title="Compact view"
aria-pressed={density === "table"}
title="Table view"
className={cn(
"rounded-md p-1.5 transition-colors",
prsDensity === "compact"
density === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Rows3 className="h-3.5 w-3.5" />
<Table2 className="h-3.5 w-3.5" />
</button>
</div>
</div>

<TabsContent value="my-prs">
{renderPrsContent(
authoredPrs,
FileText,
"No open PRs",
"Click Refresh to fetch your open PRs from GitHub. Make sure your repo path is configured in Settings.",
)}
</TabsContent>

<TabsContent value="review-requests">
{renderPrsContent(
reviewRequests,
Eye,
"No review requests",
"No PRs are waiting for your review. Click Refresh to check again.",
)}
</TabsContent>

<TabsContent value="all">
{loading && reviews.length === 0 ? (
<SkeletonList count={4} />
) : (
(renderProjectGroupedList(reviews) ?? (
<EmptyState
icon={Rocket}
title="No reviews yet"
description="Create a project or import from Linear under Projects, or switch to Mine to review existing GitHub PRs."
actionLabel="Go to Projects"
onAction={navigateToProjects}
/>
))
)}
</TabsContent>
</Tabs>

{/* Body fills the remaining height; table + rail scroll independently. */}
<div className="flex min-h-0 flex-1">{renderBoardBody()}</div>
</div>
);

Expand Down
Loading
Loading