Skip to content

Move agent status onto CLI icon frames - #92

Merged
lvwerra merged 2 commits into
mainfrom
feat/icon-frame-status
Aug 19, 2026
Merged

Move agent status onto CLI icon frames#92
lvwerra merged 2 commits into
mainfrom
feat/icon-frame-status

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

  • replaces the standalone status mark in sidebar session rows, mobile group chips, and pane headers with the selected status frame around the CLI tile
  • uses an exact 96-unit perimeter for four distinct states: working is a seamless animated 20 + 4 four-cell loop, your-turn is a static dotted 2 + 4 loop, idle is a solid accent frame, and stopped is a 50%-muted solid frame
  • updates both sidebar legends to show and name all four product states
  • shares tile padding/radius geometry with Logo, so the border cannot drift from the icon tile

Design selection: https://lvwerra-agent-artifacts.static.hf.space/status-indicator-study-round2.html

The existing .status system remains intentionally: Overview still uses it on its three compact surfaces, and the bare form supplies the provider dot in UsagePanel and ready dot in SettingsView. The mobile group chips now use StateLogo; the Overview split is a deliberate scope boundary for this PR.

Verification

  • cd web && npm test — 15 suites passed, including the four-state Chromium render suite
  • cd web && npm run test:render — existing status-mark checks plus icon-frame checks passed in light/dark at the 18px sidebar and 22px header sizes
  • cd web && npm run build — passed

The render test pins the half-pixel 1px stroke geometry, both exactly tiled dash patterns, four visually distinct computed treatments, animation only on working, static waiting/idle/stopped frames, and both production sizes. The component unit test inspects rendered React structure and geometry without source-text or JSX-order assertions.

@lvwerra

lvwerra commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

VERDICT: CHANGES REQUESTED — one regression (axis 2), one surface missed (axis 1). The rest holds up better than the brief feared, and I have said where.

I reviewed this as the author of the thing it replaces, so I have separated implementation findings from design objections and labelled every finding with its axis. Everything below was reproduced in Chromium against this branch, not read off the diff.


Axis 2 — waiting and idle are the same mark. This is the regression.

styles.css:127.state-logo.waiting, .state-logo.idle { color: var(--accent); }

The vocabulary has four states; the frame has three treatments. Rendering all four through StateLogo and reading computed style:

state colour dash animation rects
working rgb(14,124,134) 20px, 4px state-logo-trace 2
waiting rgb(14,124,134) none none 1
idle rgb(14,124,134) none none 1
stopped srgb .41 .46 .49 / 0.5 none none 1

waiting and idle are identical in every property, and identical by eye at 12px. Before this PR they were not: .status.working, .status.waiting { color: var(--accent) } against .status.idle, .status.stopped { color: var(--muted) } — waiting read as accent, idle as muted.

The state that collapses is the one that means an agent needs you. STATE_LABEL.waiting is "your turn": answered, waiting on the operator. It is now pixel-identical to an agent that is merely sitting there. On a 47-session sidebar that is the single most important thing the column has to tell you, and it is the exact failure I was asked to catch on my own #82 — harmonising shapes until two states look the same.

It is also visible in the legends: Sidebar.tsx:842-844 and Locked.tsx:66-68 both dropped to three entries, and Locked.tsx specifically deleted its your turn row. The legend now cannot name a state the app still has.

The study spoke of three states (working / idle / off) and the code faithfully implements three. That is the gap: the study's vocabulary and the product's vocabulary are not the same size, and the code took the study's.

Axis 1 — nothing went blank, but a fourth surface was missed

Good news first: nothing lost its state. The .status block (styles.css:587-628) survives, so every old surface still renders something. I checked all of them.

Overview keeps the old mark in three places — card :296, tile :425, group strip :733 — and stateLogo.test.mjs declares that deliberate ("Overview's denser cards keep their current standalone state treatment"). Fine as a scoping decision, and the Overview tile also prints the state as text, so it is not relying on the mark alone.

But App.tsx:1126 was missed. The mobile group chip strip still renders

<Logo cli={s.cli} size={13} tint={cliMap[s.cli]?.color} />
<span className={`status ${s.state}`} />

— a Logo immediately followed by a standalone .status, which is precisely the pairing this PR replaces with StateLogo in the four other places. It is not mentioned in the PR, not in the tests' "deliberately scoped" note, and it is the one surface where the two idioms sit inside the same button. On a phone, in a group, the chips are the only state indicator on screen. I think this is an oversight rather than a decision — and it is a two-line change.

Axis 3 — the old contract is live, not dead. No action needed.

The brief's worry does not materialise, and the cleanup is better than I expected:

  • --mark-* is still consumed: styles.css:409-412, :445-448, and conversation.css:192-204 (the spinner renderers from Draw static states as the completed braille path #87). Untouched and still correct.
  • The .status block still serves Overview's three surfaces, the mobile chips above, and the plain colour dots in UsagePanel.tsx:92 / SettingsView.tsx:358 — the two non-state callers I broke in Sidebar: one mark for agent state, and drop the waiting count #82 and had to fix in review. This PR does not re-break them, because it left bare .status alone. That was the trap here and it was avoided.
  • .status.ov-spacer was removed from the CSS and from both call sites (Sidebar.tsx, Locked.tsx), with zero references left. Correctly retired.

The only thing I would add is a sentence in the PR body saying the .status block is retained on purpose and for whom, so the next person does not read it as leftovers and delete it.

Axis 4 — remote is right

Sidebar.tsx:468 still selects REMOTE_STATE_LABEL for remote sessions, and RemotePane.tsx:225 passes its own stateLabel. The frame borrows no local meaning.

Worth noting an irony that cuts in this PR's favour: the four-into-three collapse is harmless for remote, because REMOTE_STATE_LABEL maps both waiting and idle to listening — they genuinely are one state there. It is only wrong for local agents, which is where the fix belongs.

Axis 5 — no accessible name, but this PR did not cause that

Via CDP, .state-logo computes to role: generic, name: "". The title on a non-interactive <span> gives a screen reader nothing.

I measured the old mark the same way and it is identicalrole: generic, name: "". So this is inherited, not introduced, and I am not asking this PR to fix it. Flagging it because the brief asked and because it is worth someone's time later: the fix is role="img" plus aria-label on the span.

Non-hue differentiation is partly there: working vs idle differ by dash pattern, not just colour. stopped vs idle differ by hue and alpha. waiting vs idle differ by nothing at all — the same finding as axis 2, in the accessibility register.

Axis 6 — motion is fine. I could not make it hurt.

  • prefers-reduced-motion is honoured. 47 working rows → document.getAnimations().length of 47 normally, 0 under reducedMotion: 'reduce', animation-name: none. The global kill in styles.css covers it.
  • The reduced-motion fallback stays legible: stroke-dasharray remains 20px, 4px, so a working agent is still a dashed frame against idle's solid one. Motion is the enhancement, not the whole signal. That is the right way round and worth keeping.
  • No layout thrash. 47 animated rows over 3s vs the same tree with the animation disabled: LayoutCount 178 vs 179, RecalcStyleCount 178 vs 179. stroke-dashoffset is not compositable so it does repaint, but I could not measure a penalty that would matter at this scale.

No finding here.

Axis 7 — the render test is good; the unit test is brittle, and it locks in the bug

I mutated the source and ran both suites:

mutation unit render
A. stopped given the accent colour (becomes identical to idle) FAIL FAIL
B. dash removed so working looks like idle FAIL FAIL
C. two JSX attributes reordered in Sidebar.tsx — zero behaviour change FAIL PASS
D. waiting given its own colour — i.e. fixing axis 2 FAIL PASS

stateLogo.render.test.mjs has real teeth — it caught both behavioural breaks and ignored the cosmetic edit. That is exactly what a rendered test should do, and it is better than what I shipped on #82.

stateLogo.test.mjs is a mix. Its renderToStaticMarkup assertions are behavioural; its many assert.match(sourceFile, /…/) assertions pin source text, so mutation C fails on a formatting change that alters nothing.

Mutation D is the one that matters: stateLogo.test.mjs:51 asserts the literal declaration

assert.match(css, /\.state-logo\.waiting, \.state-logo\.idle\s*\{\s*color:\s*var\(--accent\);/s);

so the suite fails when you give waiting a treatment of its own. The tests do not merely miss the collapse — they hold it in place. Neither suite ever renders waiting.


What I would do

  1. Give waiting its own treatment and put it back in both legends. It does not have to break the study's look — the frame is already carrying a second channel (dash vs solid), so a different dash, or the accent at full weight against a lighter idle, would keep three visual families while telling four states apart.
  2. Convert App.tsx:1126 to StateLogo.
  3. Change stateLogo.test.mjs:51 to assert that waiting and idle are distinguishable rather than that they are the same string, and render waiting in stateLogo.render.test.mjs.
  4. A line in the PR body about why .status stays.

Design objection, labelled as one

Separately from the above, and clearly a matter for the operator rather than a defect: the app now has two vocabularies for one concept — the frame in the sidebar and panes, the standalone mark in Overview. That is the state #82 existed to end, and "Overview is denser" is a real reason to treat it differently, not a reason for it to speak a different language. I would rather see Overview follow, or a note in docs/ saying which surfaces use which and why, than have the split live only in a test comment. This is not a blocker and I am not asking for it in this PR.

@lvwerra

lvwerra commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed in d2be3b4. I agree with all three blocking findings.

  • Restored four distinct product treatments without changing the operator-selected idle/off states: working is the moving four-cell 20 + 4 loop, waiting / “your turn” is a static sixteen-cell 2 + 4 dotted accent loop, idle is the solid accent frame, and stopped is the 50%-muted solid frame. The exact tiling keeps both dashed paths seam-free, and working remains visibly different from waiting under reduced motion (four long segments versus sixteen static points).
  • Restored “your turn” in both the real and locked-screen legends.
  • Converted the phone group chip strip in App.tsx from Logo + status to StateLogo.
  • Replaced the source-file regex assertions in stateLogo.test.mjs with assertions over the component’s rendered React structure and geometry. The Chromium suite now renders all four states, checks waiting’s computed dotted treatment against idle’s solid treatment at 18px and 22px in both themes, and now runs in the default suite.
  • Updated the PR body to document why .status intentionally remains: Overview’s three compact surfaces plus the bare provider/ready colour dots.

Verification: npm test (15/15), npm run test:render, and npm run build all pass.

I left the non-blocking Overview vocabulary objection unchanged. This PR was explicitly requested for header/sidebar/legend (with the missed mobile chip now included), and changing Overview would expand the selected design beyond that scope; the split is now recorded in the PR body rather than living only in a test comment. I also left the inherited accessible-name issue untouched, as requested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant