Files
lumbridge-code/docs/decisions/0021-sidebar-composition.md
T
Metal AgentandClaude Opus 5 72887cb4ab Rebuild the sidebar around the four questions it exists to answer
The rail showed a frozen attention count over a worktree list backed by a crate
that does not exist. What replaces it starts from a question rather than from a
list of things we happened to know: what needs me, what am I running, what did I
set aside, where does it run and what will stop me.

Layout is data. sidebar/model.rs holds no renderer types, so which sections
exist, what collapsing hides, what the filter keeps, and where the keyboard
cursor lands are ordinary tests in CI; sidebar/view.rs renders and decides
nothing. Eleven model tests, none of which need a window.

The cursor is a RowKey rather than an index, because an index is wrong the moment
a row above it disappears and silently pointing at a different row is worse than
losing the cursor. Every header renders even when its section is empty, so
positions never move under the pointer. The filter's empty state does not quote
what was typed — the sidebar is the part of the window people screenshot.

One selection language everywhere: before this, attention cards darkened on hover
while worktree rows lightened, so the same gesture meant two different things a
hundred pixels apart.

Two defects the screenshots caught that review had not. Flexbox shrinks
proportionally, so the longer string wins: the attention row rendered as
"Te… Exited with code 7 · observed", having discarded the one word that says
which pane to look at. And three quota rows all read "CLAUDE CODE" with the scope
truncated away, naming the same thing three times and identifying none of them.
Titles now have a floor and the harness name prints once per group.

WORKSPACE is deliberately flat: a Repository → Worktree → Pane tree would need
lumbridge-git, and every level above Pane would be a second fixture. The depth
field and disclosure column are reserved for when it is real. HOSTS has two
states, live or not — connecting and unreachable are unbuildable until
lumbridge-remote exists, and shipping them would be the Buzz card again in a
Rust enum.

The rail drags between 200 and 480 px, applied live so the workspace reflows
under the pointer; decision 0009 measures pane thresholds after the sidebar, so
widening really can drop three panes to one. PTYs are resized on release only, or
every mouse-move is a SIGWINCH storm through the runtime's bounded queues.

While the sidebar owns the keyboard, on_key_down returns before encoding
anything. Without that guard a bare `j` would be written into whatever pane
happened to be selected while the user believed they were walking a list.

Not persisted yet, not virtualised, and describe() has nothing to attach to until
the accessibility adapter from decision 0017 lands. Recorded in 0021.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 23:56:17 -07:00

122 lines
5.8 KiB
Markdown

# 0021: The sidebar answers four questions, and its layout is data
Status: accepted; rebuilt and live.
The old rail showed a frozen `ATTENTION · 1` over a five-entry worktree list
naming a developer's machines, and a Buzz card asserting a connection to a crate
the binary did not depend on. All of that is gone (decision 0018's commit and the
truth pass before it). What replaces it starts from a question rather than from a
list of things we happened to know.
## The four questions
A workspace multiplexer's rail exists to answer, in this order:
1. **What needs me?**`NEEDS YOU`
2. **What am I running?**`WORKSPACE`
3. **What did I set aside?**`DETACHED`
4. **Where does it run, and what will stop me?**`HOSTS`, `QUOTA`
Anything that answers none of those does not belong in the rail. That is the test
the worktree list failed, and it is the test any future addition has to pass.
`WORKSPACE` is deliberately **flat**. A Repository → Worktree → Pane tree is the
obvious shape and every level above Pane would be a second fixture, because
`lumbridge-git` does not exist. The `depth` field and the disclosure column are
reserved so the tree can arrive without moving every row.
## Layout is data
`sidebar/model.rs` holds no renderer types. `flatten(&SidebarInput,
&SidebarState) -> Vec<SidebarRow>` is a pure function, so which sections exist,
what collapsing hides, what the filter keeps and where the keyboard cursor lands
are ordinary tests in CI. `sidebar/view.rs` renders and decides nothing.
Two rules that came out of writing the tests rather than the view:
- **The cursor is a `RowKey`, not an index.** An index is wrong the moment a row
above it disappears — a pane exits, a filter narrows — and quietly pointing at
a different row than the one the user was looking at is worse than losing the
cursor. An unknown key restarts at the top.
- **Every header renders, even for an empty section**, and every empty section
says what empty means. Positions then never move under the pointer because
something appeared above.
The empty state under a filter is "No match in this section" and does **not**
quote what was typed. The sidebar is the part of the window that gets
screenshotted and shared.
## One selection language
Rest transparent, hover `surface_raised`, selected `surface_active`, keyboard
cursor a one-pixel border — accent when the rail has the keyboard, `border` when
it does not. Before this the attention cards *darkened* on hover while the
worktree rows *lightened*, so the same gesture meant two different things a
hundred pixels apart.
Every row has the same slots — disclosure, indicator, kind, label, meta — always
reserved. A row that omitted its indicator column pulled its label left and broke
the vertical line the eye follows.
One indicator per row, by precedence: faulted, then waiting, then quota nearly
spent, then finished, then working. A row showing two of those tells you to look,
which you already knew, and hides which one to act on.
## What the layout work actually caught
Flexbox shrinks proportionally, so the longest string wins. The attention row's
reason is longer than its title, and the row rendered as `Te… Exited with code 7
· observed` — it had thrown away the one word that says which pane to look at.
Titles now carry a minimum width and the meta yields first. Three quota rows all
read `CLAUDE CODE` with the scope truncated off the end, naming the same thing
three times and identifying none of them; the harness name is now printed once
per group, as the footer already did.
Both were found by screenshot, not by review.
## The keyboard
`secondary-alt-b` toggles, `secondary-alt-s` moves the keyboard into the rail,
`secondary-alt-a` jumps to the next pane that is *certainly* waiting. Inside the
rail, arrows and `j`/`k` move, `space` peeks and stays, `enter` activates and
hands the keyboard to what it opened, `escape` leaves.
The critical part is a guard, not a feature: `on_key_down` returns before it
encodes anything for a terminal while the sidebar owns the keyboard. Without it a
bare `j` would be written into whatever pane happened to be selected while the
user believed they were walking a list.
## Resizing
The rail drags between 200 and 480 pixels, applied live. Widening it really can
drop the workspace from three panes to one, because decision 0009 measures its
thresholds *after* the sidebar — that is correct, and hiding it until release
would make the result look like a bug. The PTYs are told once, on release: a
resize per mouse-move would be a SIGWINCH storm through the runtime's bounded
queues.
The drag is owned by the root element rather than by the handle. A child that
only sees events inside its own five pixels cannot follow a pointer across the
window.
## Not in this pass
- **Persistence.** Width, collapsed sections and visibility return to defaults on
restart. They belong in their own snapshot, separate from the workspace blob so
the two fail closed independently.
- **Virtualisation.** The list renders every row. At the number of panes a person
has open this is not measurable; it becomes real when a host has fifty.
- **`HOSTS` beyond the local machine.** Two states only, live or not. Connecting,
retrying and unreachable are unbuildable until `lumbridge-remote` exists, and
shipping them would be the Buzz card again in a Rust enum.
- **Accessibility.** `describe` produces the sentence for each row and is tested;
there is nothing to attach it to until the adapter in decision 0017 lands.
## Behaviour studied
Zed's project panel (GPL — behaviour only, no code read for reuse), Zellij's
cursor clamping after deletion (MIT), bb's thread-list indicator precedence
(MIT), Orca's host list (MIT). Nothing was copied; the row anatomy, the
precedence chain and the key-cursor rule are convergent answers to the same
problems.