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.
) : (
-
-
-
- | Status |
- Title |
- Repo |
- Sessions |
- PRs |
- Activity |
- Updated |
-
-
-
- {objs.map((o) => (
- nav(`/objectives/${o.id}`)}
- className="cursor-pointer transition-colors hover:bg-raised/60"
- >
- |
-
-
- {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 (
+
+
+
+ | Status |
+ Title |
+ Repo |
+ Sessions |
+ PRs |
+ Activity |
+ Updated |
+
+
+
+ {objectives.map((o) => (
+ nav(`/objectives/${o.id}`)}
+ className="cursor-pointer transition-colors hover:bg-raised/60"
+ >
+ |
+
+
+ {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 = {