diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md index 38eb0937..602b005b 100644 --- a/docs/2-handbook/swarm/ui.md +++ b/docs/2-handbook/swarm/ui.md @@ -322,6 +322,8 @@ when it fires next. **edit** turns the card into its own form; **pause** and list by name, agent or state (`active`, `paused`, or `past` for a one-shot whose time has passed). `past` is the controller's clock against the start time, not a record that an agent fired, so you can still pause a `past` schedule. +While the schedules load for the first time, the card shows grey placeholder +cards with the same layout as the real ones. A schedule repeats **every** some number of seconds, minutes, hours or days, or fires **once**. Its start time is the moment a one-shot fires, and the origin of @@ -357,7 +359,8 @@ and **duplicate** buttons. **create flow**, at the top of the page beside the refresh interval, starts a custom flow with just a source. Creating and duplicating open the editor on a draft and make nothing: the flow exists once you **save** it there. Leaving the draft without saving, or discarding it, leaves -the flows as they were. +the flows as they were. While the flows and the recent deliveries load for +the first time, the card shows grey placeholders of the same shape. A switch sends the whole flows document with the revision the page read, `0` if it read no document. The controller refuses the switch when the stored revision diff --git a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx index 8be87289..91f51cf9 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx @@ -4,6 +4,7 @@ import { EmptyState } from "../../ui/empty-state/EmptyState.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { Table, type TableColumn } from "../../ui/table/Table.js"; +import { TableSkeleton } from "../../ui/table/TableSkeleton.js"; import { issueRef, outcomeText, type Delivery } from "./RouterLive.js"; // A delivery with its place in the answer, which is what tells two identical rows @@ -48,6 +49,11 @@ const COLUMNS: TableColumn[] = [ }, ]; +/** The table's twin while the deliveries load. */ +export function DeliveriesSkeleton() { + return ; +} + export function Deliveries({ rows }: { rows: Delivery[] }) { if (rows.length === 0) return no deliveries yet; const placed = rows.map((d, place) => ({ ...d, place })); diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index ae00bed4..262dd682 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -26,6 +26,7 @@ import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { Hint } from "../../ui/hint/Hint.js"; import { Panel } from "../../ui/panel/Panel.js"; +import { SkeletonLines } from "../../ui/skeleton/Skeleton.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js"; import { FlowCanvas } from "./FlowCanvas.js"; @@ -248,7 +249,7 @@ export function FlowEditorPage({ if (!view) { return ( -

loading…

+
); } diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowListSkeleton.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowListSkeleton.tsx new file mode 100644 index 00000000..30f5f5b5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowListSkeleton.tsx @@ -0,0 +1,30 @@ +// The twin of the flow cards for a list that is loading: the same card and +// head layout (`notify-flow-head`), a placeholder for the name and the +// buttons, and a block where the flow's graph goes. The list is in the +// container the real one uses (`notify-flows`), so the gap is the same. +import { Card } from "../../ui/card/Card.js"; +import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js"; + +function FlowCardSkeleton() { + return ( + +
+ + +
+ +
+ ); +} + +export function FlowListSkeleton({ count = 3 }: { count?: number }) { + return ( + +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index fd05f07e..058d29ef 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -20,7 +20,8 @@ import { type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; -import { Deliveries } from "./Deliveries.js"; +import { Deliveries, DeliveriesSkeleton } from "./Deliveries.js"; +import { FlowListSkeleton } from "./FlowListSkeleton.js"; import { NEW_FLOW_PATH } from "./FlowDraft.js"; import { FlowGraph } from "./FlowGraph.js"; import { withBuiltin, type FlowsView } from "./FlowTypes.js"; @@ -124,7 +125,7 @@ export function NotificationRouter() { problem={actionError} /> ) : null} - {!error && view === null ?

loading…

: null} + {!error && view === null ? : null} {view ? (
{dryError ? ( @@ -191,7 +192,7 @@ export function NotificationRouter() { ) : deliveries ? ( ) : ( -

loading…

+ )}
) : null} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css index 62ab4d96..42dcd0c8 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css @@ -31,3 +31,19 @@ .ui-schedule-card-actions { gap: 0.5em; } +/* The loading twin's list: the gap between cards in the real list. */ +.ui-schedule-card-list { + display: flex; + flex-direction: column; + gap: 0.5em; +} +/* The loading twin's rows take the height of a line of text, and its head the + height of the title, so the card is the size of a real one. */ +.ui-schedule-card-head-skeleton { + min-height: 1.76em; +} +.ui-schedule-card-line { + display: flex; + align-items: center; + min-height: 1.6em; +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx index 31f3c22f..7ee321e5 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx @@ -7,6 +7,7 @@ import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; +import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js"; import { fmtInterval, fmtLocal, @@ -87,3 +88,49 @@ export function ScheduleCard({ ); } + +// The twin of `ScheduleCard` for a list that is loading: the same card and +// layout classes, with a placeholder in each slot. The cards sit in the +// container the real list uses, so the gap between them is the same. +function ScheduleCardSkeleton() { + return ( +
+ +
+ + + +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + +
+
+ ); +} + +export function ScheduleListSkeleton({ count = 3 }: { count?: number }) { + return ( + +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx index 86cf3c3d..2a4f3aa4 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -24,7 +24,7 @@ import { import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import type { TableColumn } from "../../ui/table/Table.js"; import type { AgentRow } from "../../agent/AgentTypes.js"; -import { ScheduleCard } from "./ScheduleCard.js"; +import { ScheduleCard, ScheduleListSkeleton } from "./ScheduleCard.js"; import { agentNamesFrom, agentOptionsFor } from "./agent-options.js"; import { ScheduleForm } from "./ScheduleForm.js"; import { scheduleState, type Schedule } from "./ScheduleTypes.js"; @@ -184,7 +184,7 @@ function SchedulesBody() { onCancel={() => setCreating(false)} /> ) : null} - {!error && schedules === null ?

loading…

: null} + {!error && schedules === null ? : null} {schedules ? (