From dffa9ccb027eabc5473da08072867df08433fc44 Mon Sep 17 00:00:00 2001 From: Nathan Whitaker Date: Fri, 12 Jun 2026 21:37:50 +0000 Subject: [PATCH] feat(ui): separate canceled objectives and sessions into collapsed sections Canceled items previously mixed into the main objective and session lists (and counted toward the Sessions 'Done' filter). Add a shared Collapsible primitive and use it to surface canceled objectives/sessions in collapsed-by-default sections: - Overview: primary Objectives table excludes canceled; canceled appear in a collapsed section when present (table extracted into ObjectivesTable). - Sessions: canceled excluded from the main table and filter counts ('Done' no longer includes canceled); shown in a separate collapsed section. - Objective detail: non-canceled sessions render first; canceled sessions move to a collapsed-by-default section when any exist. - Add chevron icon for the collapsible toggle. --- ui/src/icons.tsx | 1 + ui/src/pages/Objective.tsx | 19 ++++++- ui/src/pages/Overview.tsx | 105 +++++++++++++++++++++++-------------- ui/src/pages/Sessions.tsx | 18 +++++-- ui/src/ui.tsx | 35 +++++++++++++ 5 files changed, 135 insertions(+), 43 deletions(-) diff --git a/ui/src/icons.tsx b/ui/src/icons.tsx index 6ee07e2..e58606f 100644 --- a/ui/src/icons.tsx +++ b/ui/src/icons.tsx @@ -12,6 +12,7 @@ const PATHS = { refresh: "M21 12a9 9 0 1 1-2.6-6.4L21 8 M21 3v5h-5", external: "M15 3h6v6 M21 3l-9 9 M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6", back: "m15 18-6-6 6-6", + chevron: "m6 9 6 6 6-6", help: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2.6-3 4 M12 17.5h.01", check: "M20 6 9 17l-5-5", alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z", diff --git a/ui/src/pages/Objective.tsx b/ui/src/pages/Objective.tsx index 1a890fd..e88777b 100644 --- a/ui/src/pages/Objective.tsx +++ b/ui/src/pages/Objective.tsx @@ -6,6 +6,7 @@ import { Button, Card, Chip, + Collapsible, EmptyState, Pill, SectionTitle, @@ -37,6 +38,8 @@ export function ObjectivePage({ // Defend against null lists from older servers (Go nil slices encode as null). const obj = detail.data.objective; const sessions = detail.data.sessions ?? []; + const activeSessions = sessions.filter((s) => s.status !== "canceled"); + const canceledSessions = sessions.filter((s) => s.status === "canceled"); const pull_requests = detail.data.pull_requests ?? []; const questions = detail.data.questions ?? []; const artifacts = detail.data.artifacts ?? []; @@ -147,10 +150,24 @@ export function ObjectivePage({ {sessions.length === 0 ? ( No sessions yet. + ) : activeSessions.length === 0 ? ( + No active sessions. ) : ( - + )} + {canceledSessions.length > 0 && ( +
+ + + + + +
+ )}
diff --git a/ui/src/pages/Overview.tsx b/ui/src/pages/Overview.tsx index 1b8105e..266fd11 100644 --- a/ui/src/pages/Overview.tsx +++ b/ui/src/pages/Overview.tsx @@ -6,6 +6,7 @@ import { Icon } from "../icons"; import { Button, Card, + Collapsible, EmptyState, Field, Modal, @@ -39,6 +40,8 @@ export function Overview({ nav }: { nav: (to: string) => void }) { const [creating, setCreating] = useState(false); const objs = objectives.data ?? []; + const activeObjs = objs.filter((o) => o.status !== "canceled"); + const canceledObjs = objs.filter((o) => o.status === "canceled"); const running = (sessions.data ?? []).filter( (s) => s.status === "running", ).length; @@ -80,46 +83,10 @@ export function Overview({ nav }: { nav: (to: string) => void }) { No objectives yet — create one and a manager session will start planning. + ) : activeObjs.length === 0 ? ( + No active objectives. ) : ( - - - - - - - - - - - - - - {objs.map((o) => ( - nav(`/objectives/${o.id}`)} - className="cursor-pointer transition-colors hover:bg-raised/60" - > - - - - - - - - - ))} - -
StatusTitleRepoSessionsPRsActivityUpdated
- - - {o.needs_user && } - - {o.title}{o.repo || "—"}{o.active_sessions}{o.pr_count} - {o.latest_activity || "—"} - - -
+ )} {objectives.error && ( @@ -127,6 +94,16 @@ export function Overview({ nav }: { nav: (to: string) => void }) { )} + {canceledObjs.length > 0 && ( +
+ + + + + +
+ )} + {creating && ( setCreating(false)} @@ -140,6 +117,56 @@ export function Overview({ nav }: { nav: (to: string) => void }) { ); } +function ObjectivesTable({ + objectives, + nav, +}: { + objectives: api.DashboardObjective[]; + nav: (to: string) => void; +}) { + return ( + + + + + + + + + + + + + + {objectives.map((o) => ( + nav(`/objectives/${o.id}`)} + className="cursor-pointer transition-colors hover:bg-raised/60" + > + + + + + + + + + ))} + +
StatusTitleRepoSessionsPRsActivityUpdated
+ + + {o.needs_user && } + + {o.title}{o.repo || "—"}{o.active_sessions}{o.pr_count} + {o.latest_activity || "—"} + + +
+ ); +} + function Stat({ label, value, diff --git a/ui/src/pages/Sessions.tsx b/ui/src/pages/Sessions.tsx index 79e0ef9..ebcad1c 100644 --- a/ui/src/pages/Sessions.tsx +++ b/ui/src/pages/Sessions.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import * as api from "../api"; import { usePoll } from "../hooks"; -import { Card, EmptyState } from "../ui"; +import { Card, Collapsible, EmptyState } from "../ui"; import { SessionTable } from "./Objective"; const FILTERS = [ @@ -22,7 +22,7 @@ function matches(filter: FilterKey, status: string): boolean { case "waiting": return ["waiting_user", "waiting_capacity"].includes(status); case "done": - return ["succeeded", "failed", "canceled"].includes(status); + return ["succeeded", "failed"].includes(status); } } @@ -33,7 +33,10 @@ export function SessionsPage({ nav }: { nav: (to: string) => void }) { ); const [filter, setFilter] = useState("all"); - const all = sessions.data ?? []; + // Canceled sessions are kept out of the main table and filter counts; they + // live in their own collapsed section below. + const all = (sessions.data ?? []).filter((s) => s.status !== "canceled"); + const canceled = (sessions.data ?? []).filter((s) => s.status === "canceled"); const shown = all.filter((s) => matches(filter, s.status)); return ( @@ -73,6 +76,15 @@ export function SessionsPage({ nav }: { nav: (to: string) => void }) { )} + + {canceled.length > 0 && ( + + + + + + )} + {sessions.error && (

{sessions.error}

)} diff --git a/ui/src/ui.tsx b/ui/src/ui.tsx index 7c9d63f..22d9a4b 100644 --- a/ui/src/ui.tsx +++ b/ui/src/ui.tsx @@ -110,6 +110,41 @@ export function EmptyState({ children }: { children: ReactNode }) { ); } +// Collapsible is a section that hides its contents behind a clickable header, +// collapsed by default. Used for low-priority groups (e.g. canceled items). +export function Collapsible({ + title, + count, + defaultOpen = false, + children, +}: { + title: ReactNode; + count?: number; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open && children} +
+ ); +} + // ---- buttons ---- const BUTTON_VARIANTS = {