fix(desktop): a page's empty state fills the room under its header and stands a little above its middle
Summary
Dean, with a screenshot of the empty Automations page: "I feel like the empty state should be centered. Can you fix this and all other empty states that make sense."
Cause. EmptyState centred itself with margin: auto, which only works when its parent is a flex column that fills the pane. That was true for the Inbox pane, the side panel, and a plan's transcript, so those centred, though at the true middle. The Automations scroller is a block box, so its empty state sat at the top. Settings › Experimental put it in a fixed min(420px, 60vh) box, so it sat wherever that box ended.
Fix, once, in the shared component. EmptyState (apps/desktop/src/renderer/empty-state.tsx) is now a .empty-state box that:
- fills the room its pane leaves under the pane's header (
flex: 1); - centres its glyph and line horizontally;
- places them vertically at the optical centre: two spacers share the free room 2:3 above and below, which puts the state's middle about 41–43% of the way down;
- scrolls inside the pane when the window is shorter than the state. The spacers shrink to nothing, so the state never runs under the header.
Three panes scroll a list as a block box. Each lays itself out as a column while it holds an empty state: one :is(…):has(.empty-state) rule beside .empty-state in desktop.css. They are .automations-scroll, .inbox-plans-scroll, and .settings-page-scroll. The one-off .settings-empty box is deleted. There are no per-page margins.
| Place | Before | After |
|---|---|---|
| Automations, no automations | Top of the pane, ~60px under the header | Optical centre below the header |
| Automations › an automation that's gone | Top of the pane | Optical centre |
| Settings › Experimental, no experiments | Middle of a 420px box under the page title | Optical centre of the page below the title |
| Window Inbox, nothing waiting (two columns) | True centre of the item pane | Optical centre of the item pane |
| Window Inbox, one-column tier | A grey "Nothing needs you" note at the top of the list | The Inbox's empty state (glyph and line) centred in the list |
| A plan's Inbox tab, one-column tier | Same note at the top of the list | Same as the window Inbox |
| A new automation's / an automation's Runs panel, no runs | A note under "Runs" | Clock glyph, "No runs yet", and how to have one, centred in the panel |
| Side panel Code, an agent's Changes, a plan's Transcript | True centre | Optical centre (same component) |
Left alone, on purpose:
- Inline notes inside a list or among other content: the sidebar's "No plans yet.", "Nothing needs you" under Home's waiting section, Settings search's "No results", pickers' "No matching skills", the terminal shells list's "No shells.", and find-in-tab's "No results".
- Factory empty: "Nothing is happening right now." and the New plan prompt sit under the Plan → Build → Ship cards. The cards are the page's content, so the prompt belongs under them rather than in the middle of the window.
- Home with nothing waiting: Home's composer and sections are its content; "Nothing needs you" is a line in one of them.
- The planless agent page: before any message it shows the agent's starting line, a wait rather than an empty state.
- Waits and failures (spinners,
ReadFailure) and.desktop-empty/.side-panel-empty: the plan that can't be drawn, "Choose a plan", a version history with no history, a file or page that can't be read. These are already vertically centred in their panes, and the component's own doc says a wait or a failure isn't empty.
RENDERS
The images are the real DesktopShell and stylesheet, built with esbuild into a small harness page and rendered by headless Chrome on Linux at 2×. The data is fixture data: workspace Acme, Automations and Factory experiments on, an empty automations list, and an empty Inbox. The Experimental page is drawn with its experiment list emptied.
Evidence
./scripts/check.sh: exit 0 (repository contracts, typecheck, lint/knip/discarded-effect, every vitest project, builds)- New and changed tests:
desktop-inbox.test.ts: an Inbox with nothing in it shows its empty state in the pane beside the list, and in the list itself once the list stands alone.desktop-plans.test.ts: a plan's Inbox tab in the one-column tier shows the empty state in the list.automations-page.test.ts: the Runs panel with no runs shows the empty state ("No runs yet" over "Run now to try it.").- All three fail on
mainand pass with this change. The existingEmptyStatetests inexperimental-page.test.tsand the side-panel tests pass unchanged.
- Filling and centring are layout. jsdom lays nothing out and the repository has no browser test layer, so the renders above are the evidence for them. They include a window 300px tall and one 220px tall, where the state starts below the header and scrolls in its pane.
- Mac: CI runs only on Linux and couldn't check the Mac. I didn't run the Electron app. What I checked was the shell's DOM (jsdom tests) and its rendering (headless-Chrome images above). The change is CSS and React markup only; nothing in main, preload, or the host is touched.
- Test count: +1 test (
desktop-inbox.test.ts). Two existing tests gained an assertion.
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens unchanged (
LUKE_UPDATE_FIXTURESnot run, or diff explained line by line). (CI) - Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root
AGENTS.md"Testing") - Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files. (CI via anti-slop) - No
Effect.runPromise/runSync/runForkoutside the runtime edges and the shimstools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-run-promise-outside-edges) - No new
setTimeout/setInterval/new Promise/AbortController/fs.watchoutside the filestools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-raw-async-primitives) - No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via
pnpm discarded-effect) - Test count equals the pre-PR count or the delta is listed. (manual:
vitest run --reporter=json) - Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root
AGENTS.md's "The permanent adaptors" with the reason it stays. - Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording) -
PRIVACY.mdunchanged, or the change is a product decision called out in the PR body. - Package
package.jsondeps match what the sources import by bare specifier;effectviacatalog:. (CI) - Barrel/door rule: no Node-reaching Effect module (
@effect/platform-node,effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderernode:grep plus a new grep for those specifiers in renderer bundles)
Notes
- Blockers or follow-up verification: please glance at the empty Automations page and Settings › Experimental on the Mac.
🤖 Generated with Claude Code
Summary by cubic
Centers empty states in desktop panes so they fill the room under the pane's header and sit slightly above its middle, instead of hugging the top or landing in a fixed box.
- The shared
EmptyStatenow fills its pane and stands its glyph and line at the optical center, scrolling when the window is shorter than the state. - Automations, the one-column Inbox list, a plan's Inbox tab, an automation's empty runs panel, and Settings › Experimental now use this centered state instead of a note or a fixed-height box.
- Inline notes inside lists, waits, and failures are left as they were.