Home mosaic, multi-select delete, offline banner and loading states

- Home puts the dashboards beside the flows: two equal-height columns,
  capped and scrollable, most recently worked on first. Each tile is a
  schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
  create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
  and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
  code-split pages, and skeletons where an empty list used to flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:02:14 +02:00
co-authored by Claude Opus 5
parent 6d84316ce5
commit f17d51c12f
13 changed files with 837 additions and 89 deletions
+58 -23
View File
@@ -4,7 +4,9 @@ import { AlertCircle, Workflow } from "lucide-react"
import { useState } from "react"
import { type FlowSummary, FlowsService } from "@/client"
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { BrainView } from "@/components/Flow/BrainView"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { HealthActivity } from "@/components/Health/HealthActivity"
@@ -30,6 +32,17 @@ export const Route = createFileRoute("/_layout/")({
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
/**
* How tall the two lists beside each other are allowed to get: about six flow
* rows, and whatever the mosaic fits in the same space. Past it each column
* scrolls on its own rather than pushing the health block off the screen.
*/
const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2"
/** DESIGN-GUIDELINES.md → Typography, the canonical section header. */
const HEADER =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
function FlowRow({ flow }: { flow: FlowSummary }) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
@@ -108,8 +121,11 @@ function Home() {
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
// Its own query beside the flows one, so neither list waits for the other.
const boards = useQuery(dashboardsQueryOptions())
const flows = data?.data ?? []
const flows = [...(data?.data ?? [])].sort(byRecency)
const dashboards = [...(boards.data?.data ?? [])].sort(byRecency)
return (
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
@@ -126,28 +142,47 @@ function Home() {
rather than flow count: a flow made a minute ago has none. */}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
<Card className="gap-0 py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
{/* One grid row holding both: a grid item stretches to the row, so the
two columns come out exactly as tall as each other whatever is in
them — and with one flow and one dashboard that is simply the taller
of the two, which is the floor the cap never goes under. */}
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
<section className={LISTS}>
<h2 className={HEADER}>Flows</h2>
<Card className="gap-0 overflow-y-auto py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
</section>
<section className={LISTS}>
<h2 className={HEADER}>Dashboards</h2>
<Card className="gap-0 overflow-y-auto py-0">
<DashboardMosaic
dashboards={dashboards}
isPending={boards.isPending}
/>
</Card>
</section>
</div>
<HealthOverview range={range} onRangeChange={setRange} />
<HealthActivity range={range} />