Skip to content

<!-- Written per the the-loop:writing skill. -->

Design: de-cluttering the control plane UI — three levels, one pick ​

Phase 2 of 3 (requirements → design → tasks). Derives from the approved requirements. Reviewed with testing-plan.md at the design-approval gate, where the owner names the option to build.

Overview ​

Three prototypes, strictly nested, generated from one template so they cannot drift: Option A — Trim removes the duplicates and the bookkeeping and keeps every column, band and section; Option B — Focus also collapses the sidebar rows to one line and folds the session panel's facts behind disclosures; Option C — Minimal also drops the session panel as a column, folds the graph strip into the header, and reduces the sidebar to two groups. The owner opens the three files under design/ (or the screenshots beside them), and names one at the gate. Nothing is added: every change is a removal, a relocation behind an existing idiom (<details>, a copy control, a popover), or a condensation drawn with the existing primitives.

TodayA — TrimB — FocusC — Minimal
Inventory elements drawn by default on the Work surface (#214 selected, a worker, both panels open, nothing expanded)56423127
Times the current node is written as a statementup to 5111
Times the tmux target is written311 (behind a disclosure)1 (behind a disclosure)
Times the repository is written4111
Columns · bands in the main column3 · 53 · 53 · 52 · 3 (+1 on Full graph)

The counts are the inventory's tallies (§ Element inventory); the implementation briefing restates them from the built app (R7.2).

TodayOption AOption BOption C

Architecture ​

The Work surface keeps its shape — sidebar, main column, session panel — and the change is in what each band renders. The three options are one rendering with three levels of omission, so the implementation is one code path per component with the chosen level baked in, not a runtime setting (R5.5: no new setting).

What every level does to the data flow: nothing. api/, state/ and demo/ are untouched (R5.1); the views read the same WorkItemView and call the same TheLoopApi methods from the same user actions. The one view-model change is railNote, which stops naming the node it is drawn beside.

Components & interfaces ​

One row per component, one column per level. A cell inherits the column to its left: B does everything A does, C everything B does (R1.2).

ComponentA — TrimB — Focus (in addition)C — Minimal (in addition)
SidebarRow = dot · #n · title (two lines) · instance chip, plus a second line only for an urgent flag (needs input, human gate, blocked). Repo, node, age and non-urgent flags leave the row. Group counts render only on Needs you. The Standing group renders only when a standing session exists.Row is one line: title truncated, the urgent flag inline at the right. PR rows nest under the selected item only, and drop their age. Standing rows drop the description.Two groups: Needs you and Everything else (sorted by last activity). No Standing group (the Standing surface keeps it). Search and the instance filter sit behind one icon button in the brand row (aria-expanded), closed by default.
HeaderBar meta (from WorkItemDetail)ref chip · instance chip (fleet) · phase chip · updated <age>. The repository line leaves (it is in the ref chip).ref chip · phase chip · updated <age>. The instance chip stays on the sidebar row only. The ref chip stays at every level: it is the one default-visible copy of the repository (R2.1; critic finding C2).ref chip · phase chip · the parked/blocked note · updated <age> · Full graph control.
HeaderBar actionscopy ref · GitHub · theme · Open session panel (when closed)samepause/resume/stop/start as ControlButtons, preceded by a visible work item kicker when a PR session is selected (the verbs act on the work item's session, as today's section title said; critic C3) · copy ref · GitHub · theme · Session details (a <details> whose summary is an IconButton and whose body is the facts popover, bounded to the viewport and scrolling inside; critic C4). The Open session panel button is gone with the panel.
GraphStripLine = loop name · the parked/blocked note (no n of m, no skipped, no at <node>), Full graph at the right; compact node row beneath, the current node highlighted.sameThe band renders only while Full graph is on: the loop name and the full node row. The line's content moved into the header meta.
SessionTabsRenders only when the item has more than one session (R2.6); the n sessions · … tally leaves.samesame
Trace headHarness trace · <harness> · VerboseHarness trace · Verbose (the harness name is in the Harness facts)same
GateBannerHuman gate · waiting <age> · reason · Approve — advance graph · Request changes on the ticket. The node name leaves (it is the phase chip).samesame
QuestionBannerunchangedunchangedunchanged
ChatBarInput · send. The hint line renders only the blocked reason; delivers to tmux · <target> · bracketed paste · ⌘⏎ leaves (⌘⏎ stays in the send button's title).samesame
SessionAsideHead · Harness (harness, status, started, session id abbreviated + copy, transcript file name + copy) · tmux (cwd last segment + copy, the attach command button) · Ticket / Pull request (inner loop for a PR, Open on GitHub) · Controls. Leaves: the note banner (now the strip's), instance, current node, the tmux target row (the attach command carries it), repo and number (the header's).Controls first; Harness, tmux and Ticket become <details> sections closed by default (Section gains a collapsible prop). Inside a disclosure the session id, transcript path and cwd render in full, as today's KV rows, with no copy control (R2.3 is met by the disclosure; R5.5 forbids a new control — critic C5). Only the attach-command copy stays.Not rendered on Work. Its facts render as SessionFacts inside the header's Session details popover; its verbs render in the header actions. Panels.asideOpen is unused on Work and the header's open-panel button is not rendered.
railNote (WorkItemDetail.tsx)parked — awaiting a human (no at <node> suffix); the attention detail otherwise, as today.samesame
HealthDot, DemoBanner, ConnectionBannerunchangedunchangedunchanged
Standing · Settings · Instances panesinherit the header's changes; nothing elsesamesame

No new helper for the chosen option: the facts inside the Session details disclosure render from the same typed fields through the same KV rows as today. (Option A alone would have needed abbreviateId / lastSegment formatters; it is not built.)

Element inventory — one verdict per element per option (R1.3) ​

keep · relocate → where · remove. The reason is the one that applies to the first level that acts; a later level inherits it.

#Band · elementA — TrimB — FocusC — MinimalReason
1Sidebar · brand row (logo, wordmark, collapse)keepkeepkeeporientation
2Sidebar · surface nav (Work · Standing · Instances)keepkeepkeepthe only way between surfaces
3Sidebar · search boxkeepkeeprelocate → behind a search icon in the brand rowused rarely; the rows are the list
4Sidebar · instance filter (fleet only)keepkeeprelocate → same iconas 3; never on a worker (R2.6)
5Row · dotkeepkeepkeepurgency (R2.2)
6Row · #nkeepkeepkeepidentity (R2.2)
7Row · title, two lineskeepcondense → one lineone lineB: one line scans; the header has the full title
8Row · instance chip (fleet only)keepkeepkeepon a fleet it is identity (one ref on two instances is two rows)
9Row · second line · repositoryremove——not a reason to choose (R2.2); drawn once in the ref chip / Ticket
10Row · second line · current noderemove——drawn once, as the phase chip (R2.1)
11Row · second line · flagkeep only when urgent (needs input, human gate, blocked)condense → inline on the one lineinlineR2.2: a flag only when the item needs a human; paused, merged, closed are the group's job
12Row · second line · ageremove——drawn once, as the header's updated (R2.1)
13Nested PR rows (dot, icon, label)keepcondense → under the selected item onlyunder the selected item onlyB: they double the row count for items not being looked at; the tab bar lists them once selected
14Nested PR row · agekeepremove—one-line rule; the tab and the Harness facts carry the session's state
15Group header countskeep Needs you onlysamesameR2.4: the one count that changes what you do
16Groups In flight · Shipped · Idlekeepkeepcondense → Everything else, sorted by activityC: three groups to scan for things that need nothing
17Standing rows (dot, name, description)keep; the group hides when empty (R2.6)condense → name onlyremove (the Standing surface keeps them)a session with no work item among the work items
18Manage standing sessions →keepkeepremovethe surface nav already says Standing
19Footer · Settings icon + wordkeepkeepkeepthe only way to Settings besides the hash
20Footer · service linekeepkeepkeepwhere the data comes from — a trust signal
21Footer · health word + popoverkeepkeepkeepfail-closed: degraded must stay visible
22Header · titlekeepkeepkeepidentity
23Header · ref chipkeepkeepkeepthe one default-visible copy of the repository and the ref (R2.1)
24Header · instance chip (fleet)keepremove (the row keeps it)—drawn once
25Header · phase chipkeepkeepkeepthe one statement of the current node
26Header · repositoryremove——it is inside the ref chip (R2.1)
27Header · updated <age>keepkeepkeepthe one age (R2.1)
28Header · copy ref · GitHub · themekeepkeepkeepR5.2 actions
29Header · Open session panel (when closed)keepkeepremove (no panel)with the column
30Strip · loop namekeepkeeprelocate → the revealed node row's labelC: the line is gone
31Strip · n of m nodes passedremove——bookkeeping (R2.4)
32Strip · n skippedremove——bookkeeping; the node row strikes them through
33Strip · at <node> / blocked at <node>remove——second statement of the position (R2.5); the chip and the highlighted node say it
34Strip · parked/blocked notekeep (the one qualifier)keeprelocate → header metaR2.5: the position's qualifier, once
35Strip · Full graphkeepkeeprelocate → header meta; reveals the bandR3.3
36Strip · compact node rowkeepkeeprelocate → behind Full graphC: one line
37Tabs · one tab per sessionkeep when >1 sessionsamesameR2.6
38Tabs · n sessions · 1 work item loop · …remove——bookkeeping (R2.4)
39Trace head · Harness tracekeepkeepkeepnames the column and anchors the switch
40Trace head · harness namekeepremove—R3.2; the Harness facts carry it
41Trace head · VerbosekeepkeepkeepR5.2
42Gate banner · Human gate — <node>condense → Human gatesamesamethe node is the phase chip (R2.1)
43Gate banner · waiting <age>keepkeepkeepa distinct fact: how long a human has been needed
44Gate banner · reason · Approve · Request changeskeepkeepkeepR3.4
45Question banner (all of it)keepkeepkeepR3.4
46Composer · input · sendkeepkeepkeepR3.4
47Composer · delivers to tmux · <target>remove——third copy of the target; nobody acts on it (R2.4)
48Composer · bracketed paste + enter · ⌘⏎ to sendremove (⌘⏎ stays in the send title)——hints, not facts
49Composer · blocked reasonkeepkeepkeepfail-closed
50Panel · note bannerrelocate → the strip's qualifier (34)——second copy of the note
51Panel · Harness · instanceremove (header + row)——drawn twice already
52Panel · Harness · harnesskeeprelocate → Harness disclosurerelocate → Session details
53Panel · Harness · session idcondense → abbreviated + copydisclosure, in full, no copy controlSession details, in fullR2.3: on demand; R5.5: no new control
54Panel · Harness · statuskeepdisclosureSession details
55Panel · Harness · current noderemove——fifth copy
56Panel · Harness · startedkeepdisclosureSession details
57Panel · Harness · transcript pathcondense → file name + copydisclosure, in fullSession details, in fullR2.3; R5.5
58Panel · tmux · targetremove (the attach command carries it)——R2.1: the target once
59Panel · tmux · cwdcondense → last segment + copydisclosure, in fullSession details, in fullR2.3; R5.5
60Panel · tmux · attach command (copy)keepdisclosureSession detailsthe one copy of the target, and the action on it
61Panel · Ticket · repo · numberremove (the ref chip is the one copy)——drawn once (R2.1); never restored (R1.2, critic C6)
62Panel · Ticket · inner loop (PR tab)keepdisclosureSession detailsa PR's own position; not drawn elsewhere
63Panel · Ticket · Open on GitHubkeepdisclosureSession detailsthe header's icon is the primary
64Panel · Controls gridkeepkeep, moved to the toprelocate → header actions, with a visible work item kicker on a PR selectionR3.4: one interaction; the target stays visible (R5.2)
65Panel · head (Session, close)keepkeepremovewith the column
66Demo banner · connection bannerkeepkeepkeepfail-closed (Security considerations)

Tally: the inventory rows drawn by default with #214 selected on a worker, both panels open, nothing expanded — so the fleet-only rows (4, 8, 24, 51), the gate rows (42–44), the PR-tab row (62) and the closed-panel and blocked-composer rows (29, 49) are out of every column, and a relocate counts only where its new home is drawn by default (the search icon, the header's note and Full graph, the header's verbs and the Session details control in C). Today 56 · A 42 · B 31 · C 27. The search icon and the Session details control in C are disclosure toggles in the Full graph idiom, not new capabilities (R5.5).

Where the current node is drawn — the rule the inventory follows ​

The phase chip is the statement; the node row is the map. A node's name inside the row is a member of a list, highlighted when current, not a second sentence saying where the loop is. The strip's text therefore says only the qualifier (parked — awaiting a human, or the attention detail), never at <node>; the gate banner says Human gate, never — <node>; the panel's current node row is gone. One statement, one map.

UI/UX design ​

ArtifactTypeLocation / linkCovers (screen · requirement)Status
Option A — Trimhtml-prototypedesign/option-a-trim.htmlWork · R2, R3.1, R4, R6offered, not chosen — kept as the record (R1.5)
Option B — Focushtml-prototypedesign/option-b-focus.htmlWork · R2, R3.2, R4, R6offered, not chosen — kept as the record (R1.5)
Option C — Minimalhtml-prototypedesign/option-c-minimal.htmlWork · R2, R3.3, R4, R6selected — chosen by @MadaraUchiha-314 on PR #513, 2026-10-20 (comment); decision-148. The visual contract the implementation matches.
design/screenshots/current-*.pngrendered stills of the built 20.5.1 app on the demo fixturedesign/screenshots/the baseline, #214 and the #205 gate, both themesreference
design/screenshots/{a,b,c}-01…08-*.pngrendered stills of the three prototypessameitem · full graph · disclosures open · gate · PR session · no-transcript fallback · 390 px, both themesdraft

The owner named Option C on PR #513 on 2026-10-20 ("I prefer the "minimal" option, option-c"), before the gate asked: the row above is marked selected and decision-148 records the pick. The two other prototypes stay checked in as the record of what was offered (R1.5). The design-approval gate still posts its one review request covering this document and the testing plan; the component table's C column and the inventory's C column are what the tasks derive from.

  • How to look at them: open a prototype in a browser; it needs no server. The sidebar rows and the PR rows navigate (#item/<ref>), the header's theme button switches theme, Full graph, Verbose, the panel's disclosures and the sidebar's collapse all work. Approve and the control verbs are inert. Select #205 for the gate banner, #178 for a blocked item, #187 for an item with no session (the no-transcript fallback and a blocked composer), #216 for a PR session.
  • Flows & states covered: select item → the main column (and the panel, A/B) swap; select a PR tab or row → the trace and the facts swap; Full graph ↔ Collapse graph; Verbose on/off; collapse/open each panel; theme; the gate banner with Approve; the question banner above the composer; the blocked composer with its reason; the no-transcript fallback; the demo banner. States not drawn by the prototypes but kept by the implementation unchanged: loading, empty board, connection error, missing deep link, truncated tail, stream reconnecting/fallen back, daemon stopped, degraded fleet (R5.3) — none of their components change.
  • Design system / tokens: the issue-327 token set from ui/src/styles/app.css, copied verbatim into each prototype's :root / .dark; the same five state colours, the same radius, the same ref-chip, pulse-dot and scroll-thin utilities; no new token, shadow or gradient (R4.1). Every class in the prototypes is a hand-written equivalent of a Tailwind utility the app already uses. The prototypes fetch no web font (selfContained), so they render in the system faces; the built app keeps its Google Fonts link.
  • Accessibility & responsiveness: the Session details popover (C) is bounded to the viewport (max-height: calc(100vh - 9rem), width at most the viewport less the gutter) and scrolls inside, so a long transcript path or working directory never pushes the Ticket section and Open on GitHub off a short screen; every disclosure is a native <details> (the panel sections in B, the health popover, the Session details popover in C) or a button with aria-expanded (Full graph, the search icon in C); a row's aria-label carries #n — title — flag; aria-current marks the selected row, tab and node; the trace keeps role="log", its name and tabindex; below 1280 px the panel starts closed (in C the popover always starts closed), below 768 px the sidebar starts closed and the header wraps so the title keeps its width (R6).
  • Evidence: design/screenshots/ holds the stills above, captured headlessly at 1440×900 and 390×844; the console and the network were watched during capture — no page error, no request left the file. Verification recaptures the built app.

Side by side ​

StateTodayA — TrimB — FocusC — Minimal
#214, darkcurrent 01 item darka 01 item darkb 01 item darkc 01 item dark
#214, lightcurrent 01 item lighta 01 item lightb 01 item lightc 01 item light
#205 gate, darkcurrent 04 gate darka 04 gate darkb 04 gate darkc 04 gate dark
Full graph, dark—a 02 full graph darkb 02 full graph darkc 02 full graph dark
Disclosures open, dark——b 03 disclosures open darkc 03 disclosures open dark
PR #216 session, light—a 05 pr session lightb 05 pr session lightc 05 pr session light
No transcript, light—a 06 fallback lightb 06 fallback lightc 06 fallback light
390 px, dark—a 07 narrow darkb 07 narrow darkc 07 narrow dark
390 px, Session details open, dark———c 08 narrow details dark

Data models ​

None change. WorkItemView, PullRequestView, RailNode, SessionRecord and the hash route are read as today (R5.1, R5.4). The view-side additions are two string formatters (abbreviateId, lastSegment) and, in Option C, a second group table for the sidebar:

ts
// views/grouping.ts — Option C only
export const SIDEBAR_GROUPS_MINIMAL = [
  { key: "needs-you", title: "Needs you" },
  { key: "everything", title: "Everything else" }, // in-flight | shipped | idle, by lastActivity desc
] as const;

sidebarGroup(view) keeps returning the four-way answer (the Standing and Instances panes, itemStatus and the tests read it); the sidebar maps it to the two-way table.

Error handling ​

Every failure surface is unchanged in wording and placement (R5.3): the connection banner above the columns, the health word in the footer (degraded when a daemon stops, the stream falls back or a member is down), the muted No transcript served notice with the server's reason, the tail — the last n of m entries line, the composer's blocked reason in the hint slot, the gate banner's and the controls' role="alert" error lines. De-cluttering removes nothing a failure says. The copy controls fall back silently, as today, when the clipboard is unavailable.

Scope of "visible by default" for the health word (critic C1): the health word's home is the sidebar footer at 20.5.1 and at every option, and the stream's fallen-back state is the word itself (degraded), not only the popover's sentence. A collapsed sidebar hides the footer today exactly as it will after this change, by default below 768 px; this work item neither introduces nor widens that gap and, under R5.5, adds no second home for the word. Giving the header a health signal while the sidebar is closed is a follow-up outside this ticket (§ Open questions).

Security design ​

  • AuthN/AuthZ: unchanged — the browser talks to the service over the base URL the settings hold; no new request, route or credential (R5.5). The prototypes are static files that perform no request.
  • Input validation & injection surfaces: untrusted text (ticket titles, node names, transcript lines, event fields, paths, the tmux target) reaches the DOM only through React children, in the relocated positions too: the one-line row's truncated title, the disclosure summaries and the popover's rows. renderInline is the only transformer and it still emits text nodes inside <strong> / <code>. No dangerouslySetInnerHTML, no eval, no inline handler on served content. The prototypes escape every data string through one esc() before interpolation, so a title such as <img onerror> renders as text there as well.
  • Secrets handling: none stored, moved or displayed; the attach command is built from the tmux target the service already serves.
  • Least privilege: the two clipboard writes are today's (copy ref, copy attach command), navigator.clipboard.writeText on a user gesture; nothing reads the clipboard, and no copy control is added (critic C5).
  • Fail-closed behaviour: the demo banner, the connection banner, the health word, the stream's fallen-back state and the composer's blocked reason are inventory rows 21, 49 and 66 — keep in every column. A check that cannot be made (no checkout → Approve disabled with its reason; no session → composer disabled with its reason) renders as today.
  • Abuse-case coverage: the table below.
Abuse case (requirements)MechanismNegative test
1 — markup / ** / backtick in a title, line, path or node nameReact escaping in every relocated element; renderInline emits text nodescomponent tests render a fixture title <b>x</b> and a node **n** through the one-line row, the disclosure summary, the popover and the abbreviated id, and assert textContent equals the input and no element was created
2 — content moved behind a disclosurethe <details> bodies render from the same SessionRecord / PullRequestView fields as today's KV rows, never from a string assembled in the DOMthe same tests assert the disclosure body's text derives from the typed record
  • No new attack surface, justified: this change adds no route, no request, no HTML rendering, no dependency and no setting; it removes and relocates markup the app already renders, and the two helpers it adds are pure string functions.

Testing strategy ​

The requirements map to three kinds of proof. Component tests (vitest + Testing Library, the existing suites) assert each inventory verdict for the chosen option: what a band renders by default, what a disclosure reveals, the accessible names and aria-current, the fail-closed rows, the abuse cases above, and the one-session tab rule (Sidebar.test.tsx, GraphStrip.test.tsx, WorkItemDetail.test.tsx, a new SessionAside.test.tsx, primitives.test.tsx, grouping.test.ts). Screenshot evidence (ui/scripts/screenshots.mjs, extended with the disclosure states) proves the built app against the locked prototype in both themes and at 390 px, and gives R7.2 its before/after count. The CI commands (bun run lint, bun run test, bun run build) and a bundle-size comparison prove the non-functional requirements. The executable detail — the matrix, the environment, the evidence list — is testing-plan.md.

Trade-offs & decisions ​

  • D1 — One template, three files. The prototypes are generated from one HTML template with an OPTION constant, so nesting is structural: a rule is written once as L ≥ 2 or L ≥ 3. Three hand-edited files would drift. The generator is a throwaway; the three files are the artifacts.
  • D2 — The phase chip is the one statement of the current node. R2.1 (node once) and R3.2 (B keeps the phase chip) only reconcile if the strip's at <node> goes, the gate banner drops — <node> and the panel's current node row goes — so they do, from A. The node row stays as the map (§ above).
  • D3 — The panel's note folds into the strip at A. R3.2 reads "controls and the note", but R2.1 allows the parked/blocked note once and R2.5 puts it in the strip. The floor wins, from A. The owner can overturn this at the gate; it is one Notice either way.
  • D4 — A one-session tab bar hides at every level. R2.6 is the floor; R3.3 lists the rule for C again. Applied from A, so the owner's screenshot (one session) gains a band back at no level.
  • D5 — The gate's waiting <age> stays beside the header's updated <age>. They are different facts (how long a human has been needed; when anything last happened). R2.1's "age" is the latter.
  • D6 — Identifiers: a disclosure, not a copy control (B, C). R2.3 allows "a disclosure or a copy control"; R5.5 forbids a new control. Behind a disclosure the values can be drawn in full, as today's KV rows draw them, so B and C add no copy button (critic C5). Option A alone, keeping the rows in the open, would have used the copy-control reading with abbreviated values; it is not built.
  • D7 — One-line rows truncate the title (B, C). A 19 rem column holds about twenty-two characters beside a flag such as needs input. The header carries the full title and the row's aria-label carries it for screen readers. Widening the sidebar would be a layout change the ticket did not ask for.
  • D8 — C folds the strip into the header meta rather than keeping a one-line band. A band whose only content would be the loop name and Full graph is a line for its own sake. The qualifier and the control sit beside the phase chip, and the node row appears beneath the header while Full graph is on.
  • D9 — C's controls become header actions. Pause/stop next to the icon buttons crowds the header (two mono buttons plus five icons); that is the honest cost of removing the column, drawn so the owner sees it before choosing. On a PR selection a work item kicker precedes the verbs, so the target the panel's section title used to name stays visible (critic C3).
  • D11 — R2.1 outranks R3.2's literal list. "Reduce the header meta to the phase chip and the age" would leave the repository drawn zero times by default in B and C, against the floor. The ref chip stays at every level as the one copy of the repository and the ref; what R3.2 removes is the instance chip and the repository line (critic C2, C6).
  • D10 — The prototypes render in system fonts. selfContained: true forbids the Google Fonts request; the built app keeps it. The screenshots therefore differ from the app in face, never in colour, size or spacing.

Durable decision: docs/decisions/decision-148.md — the owner's chosen level — is written when the pick is made (R1.5).

Open questions ​

  • Which option? Asked by the design-approval gate's review request, answered by a review comment on the PR naming A, B or C. Not asked separately here.
  • Carried from the requirements, still open to overturning at the gate: "main bar" read as the main column's top band plus the composer's hint; Option C's two sidebar groups.
  • Follow-up, not this ticket: a health signal in the header while the sidebar is collapsed (critic C1) — the gap exists at 20.5.1 and R5.5 forbids adding an element here.

Review comments ​

Appended by the-loop's record-feedback hook when a human gate approves with comments (issue-109). Append-only and attributed: an approval never silently discards a reviewer's suggestions, and the feedback travels with the document it concerns rather than living in a side-channel tracker.

2026-10-20 — approved ​

@MadaraUchiha-314 wrote:

approved

Released under the MIT License.