Operator brief 2026-08-22 (paired session, verbatim intent): "in neo we have ANIMATED component-based lists, that can move cards, fade-in, fade-out, select(!). we could add different sorting options (social name, online+social name, latest activity (that would move a lot => animation firework), filter => hide offline and benched. now when we have selections, the redundant bottom agent selection buttons can go away => terrible UX anyway. so much room to do better here — the product needs love."
Measured realities (dev cockpit, 2026-08-22): grid measure 935px → 2-col; every card uniformly 447×126 (the anatomy is already uniform — a fixed itemHeight is a measured constant, not a compromise); the card region is the scroll owner (scrollHeight 666 vs 275 visible). The live-fleet deployment renders 9 agents 3-col with the same shape.
Intake correction — 2026-08-23: the consumed-surface Contract Ledger is backfilled below;
native parent #17559 and merged prerequisites are folded into Related; ADR 0029 wording now cites
its actual §2.6 pane-layout-blindness contract. Clio remains the originating author; Emmy owns the
operator-directed completion from parked head 11d564107d.
The Problem
Three structural gaps, one product feel:
The roster is a rebuild, not a list.FleetGrid (apps/agentos/view/fleet/FleetGrid.mjs) is a container that removeAll(true) + re-adds every card on every store load and every tier-moving recordChange — which is why ~60 lines of focus-continuity machinery exist (the FocusManager.history intersection, SEMANTIC_CHILD_REFS restore, the Up/Down drill jump): they compensate for destroy/recreate. Nothing moves, nothing fades; a roster change flashes.
The order is fixed and the operator has no lever.rankFleet hard-ranks online → idle (folds ≥ foldThreshold) → benched, each tier sorted by agentId — not even by name. No sort choice, no filters.
Selection does not exist — so two surfaces invent pickers. A card's dedicated drill Button fires a one-shot agentSelect (AgentCardController.onCardSelect) toward the detail pane, and MemoriesPane renders its OWN roster copy as chip buttons (agentOptions_, pushed by the cockpit at FleetCockpit.mjs:1525/2811) — the redundant bottom picker the operator names as terrible UX. There is no cockpit-level "selected agent" state.
The Architectural Reality
The store is already animation-ready:reconcileRoster (FleetCockpit.mjs) mutates record-grain — record.set(row) in place, one batched add for joiners, remove for leavers. A store-driven list animates exactly these events; the ingest path needs no change.
The primitive exists:Neo.list.Component (component-based items with instance reuse via createItemContent — sibling pattern src/calendar/view/calendars/List.mjs) + Neo.list.plugin.Animate (explicit component-list sort support). The plugin's blocking gaps (resize awareness, fluid item width, owner scroll height, reduced motion, component filter parity) are #17552 — this ticket is blocked by it.
Selection is a solved contract:Neo.selection.ListModel — single-select, selectionChange, selectedCls, built-in item focus/arrow navigation — retiring the grid's hand-rolled machinery wholesale.
The provider is the seam: the cockpit's stateProvider hosts stores.fleetRoster (FleetCockpit.mjs:246); the selected agent joins it as provider data so dock-projected panes (docked, vesseled, torn out) read one truth.
Sort axes on FleetAgent: displayName (Brain-folded social name — the sample roster renders Clio/Ada/Grace/…), state (ok|idle|wedged|limited|off), participationStatus (operator_benched is the authoritative bench fact), presence.lastSeenAt (plane axis; null while presence is unobservable — today's in-process mode). No latest-activity stamp exists — Fix 4.
The Fix
FleetGrid rebuilds on Neo.list.Component — animate: true, itemHeight = the measured 126px anatomy constant, fluid columns via #17552's minItemWidth, preserving today's ladder (1-up ≤640, 2-up default, 3-up ≥1280 of grid measure; the container-query values move from FleetGrid.scss into the JS ladder input — ADR 0029 §2.6 pane-layout-blindness intact: the list measures only its own rendered surface). AgentCard becomes the component item (create/reuse keyed by record, calendar-List pattern); its inner anatomy, controller and lifecycle events are untouched. The stable head (title · stale marker · presence-cap chip · HealthBar) stays the sibling above the list; the empty-fleet CTA renders outside the list while the store is empty.
A compact sort cluster in the grid head (grid-owned — the cockpit top toolbar is #17549's surface, untouched). Three options:
Online first · name (DEFAULT — today's tier order expressed as a sorter: a derived tierRank model field from state, then case-folded displayName; continuity, not a behavior change),
Name A–Z (displayName),
Latest activity (DESC on lastActivityAt, nulls last — "that would move a lot": the firework is real once the axis is stamped).
Mechanics = the store's own sorters (examples/list/animate pattern: store.sorters[0].set({property, direction})).
Filters:hide offline (state === 'off'), hide benched (participationStatus === 'operator_benched'), and the density fold re-expressed as a filter preset: at foldThreshold (≥12) the idle tier filters out by default while the head renders the honest count ("+N idle · show") toggling it — the fold row leaves the card flow (a non-record row cannot live in a store-driven list; the count moves to the head, same truth). Store filters → the plugin fades. Session-local, not persisted.
lastActivityAt — stamped Brain-side, never view-derived: the roster DTO row (ai/services/fleet/fleetCockpitStatus.mjs, the openLaneCount precedent) gains a tri-state lastActivityAt (ISO string | null) stamped by the assembler from the newest attributable per-agent activity timestamp it already holds (activity-adapter occurredAt fold; presence.lastSeenAt merges in when the presence axis is wired). mapRosterRow passes it through. Null = not stamped → tail of the sort, honestly (in today's bearerless dev mode every row is null and the sort degrades to name order — correct, not broken).
Selection drives the panes: single-select selectionModel on the list; card click / Enter selects; selectionChange → the cockpit writes data.selectedAgentId on its provider AND runs today's detail path (record → detailRecord → reveal). The in-card lifecycle Buttons (toggle/restart) are carved out of the selection path — a control click never changes selection. The dedicated drill Button retires (the item itself is the target); agentSelect goes with it.
The redundant picker dies:MemoriesPane drops agentOptions_ + the chip row; activeAgent follows the provider's selectedAgentId (write-through replaces the pushes at 1525/2811). Null selection renders the existing "Pick an agent…" empty state — now satisfied by clicking a CARD: one picker for the whole cockpit. The pane keeps a one-line "for: " context label. (#17268 keeps the pane's content design; only the target-selection mechanism changes here.)
Retire the compensation machinery: the Up/Down KeyNavigation drill jump, SEMANTIC_CHILD_REFS, and the gate-3 focus restore in refreshGrid delete — native list item focus/arrow navigation + no-rebuild reconciliation replace them; their specs migrate to the list behaviors.
SCSS:.fm-fleet-cards's CSS grid + container queries retire in favor of plugin geometry; token-only skin for the sort/filter cluster and the selected-card state (a --fm-signal ring — selection is a state the theme owns).
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
AgentOS.view.fleet.roster.List (new consumed view)
removes agentOptions chip row; selected provider identity drives the request and a “for: name” label
null selection keeps the existing pick-an-agent empty state
class/controller JSDoc
unit target transitions + headed pane retarget
.fm-fleet-cards list/card skin
FM token layer + #17553 selected-card AC; generic list skin is not product authority
list li stays a transparent zero-padding geometry carrier in every state; AgentCard owns background, selection and focus paint
both themes resolve local neutral list tokens; stale theme assets fail the headed witness
SCSS intent comments
both-theme computed styles, item/card rect equality, hover/active/selected/focus checks
Acceptance Criteria
A sort change MOVES cards (translate transition; the same AgentCard instances survive, witnessed by instance identity across the sort) — no rebuild, no flash.
Filter toggles fade cards out/in; the fold preset auto-arms at foldThreshold with the honest head count and toggles live.
Selecting a card (click / Enter) sets the provider's selectedAgentId, reveals/updates the detail pane, and re-targets MemoriesPane — whose chip row is GONE; a control-button click never changes selection.
lastActivityAt is stamped Brain-side (tri-state), passed through mapRosterRow, and the latest-activity sort orders stamped rows DESC with nulls last (unit witnesses producer-side and view-side).
Live reconcile animates: a joiner fades in, a leaver fades out, a tier-moving state change repositions (witnessed against reconcileRoster's record-grain writes).
The focus-restore machinery and drill jump are deleted with their specs migrated; keyboard: arrows move item focus, Enter selects, Tab reaches the in-card controls.
Reduced motion honored end-to-end (the #17552 rule; no cockpit-side motion overrides).
npm run check-theme-surfaces green; tokens only.
Out of Scope
Card CONTENT anatomy (what a card says stays as shipped; #17549 owns header/detail IA).
MemoriesPane content design (#17268), the activity-stream buffered list (#17550), sort/filter persistence (cockpit presets), FleetCockpit decomposition (#17335 — additions here stay lean and factored).
Multi-select (single is the product semantic: one detail pane, one memories target).
Avoided Traps
View-derived activity recency: folding the activity feed into lastActivityAt client-side would create a second truth the producer cannot correct — the DTO owns the stamp end-to-end (the openLaneCount precedent; the model's own passthrough discipline).
Keeping both pickers "for safety": the chips' only job was selection; with a selection model they are dead weight — a half-removal leaves two sources of target truth.
Hand-rolled animation on the container: the grid could translate its own children, but that re-implements the plugin badly and keeps the rebuild. The primitive exists — consume it.
Boundaries: #17549 (header/detail IA), #17268 (pane content design), #17550 / PR #17585 (activity list + reusable list fixes), #17335 (cockpit LOC bar), #16824 (empty-roster truth — the CTA/empty semantics defer to it).
Post-merge: #14618 re-records the visual baselines (standing owner; the goldens are rendered-platform artifacts per its ledger).
Decision Record impact: aligned-with ADR 0029 §2.6 (pane-layout-blind geometry — the roster measures its own rendered list surface and contains no dock/viewport-placement knowledge).
Live latest-open sweep: checked latest 20 open issues at 2026-08-22T15:51:54Z (gh issue list --state open --limit 20); no equivalent found. A2A claim sweep at 15:52Z (latest 14, recency-bound): no competing claim; the adjacent #17550 lane-pick (Emmy) is a disjoint surface.
Context
Operator brief 2026-08-22 (paired session, verbatim intent): "in neo we have ANIMATED component-based lists, that can move cards, fade-in, fade-out, select(!). we could add different sorting options (social name, online+social name, latest activity (that would move a lot => animation firework), filter => hide offline and benched. now when we have selections, the redundant bottom agent selection buttons can go away => terrible UX anyway. so much room to do better here — the product needs love."
Measured realities (dev cockpit, 2026-08-22): grid measure 935px → 2-col; every card uniformly 447×126 (the anatomy is already uniform — a fixed
itemHeightis a measured constant, not a compromise); the card region is the scroll owner (scrollHeight 666 vs 275 visible). The live-fleet deployment renders 9 agents 3-col with the same shape.The Problem
Three structural gaps, one product feel:
FleetGrid(apps/agentos/view/fleet/FleetGrid.mjs) is a container thatremoveAll(true)+ re-adds every card on every store load and every tier-movingrecordChange— which is why ~60 lines of focus-continuity machinery exist (theFocusManager.historyintersection,SEMANTIC_CHILD_REFSrestore, the Up/Down drill jump): they compensate for destroy/recreate. Nothing moves, nothing fades; a roster change flashes.rankFleethard-ranks online → idle (folds ≥foldThreshold) → benched, each tier sorted byagentId— not even by name. No sort choice, no filters.agentSelect(AgentCardController.onCardSelect) toward the detail pane, andMemoriesPanerenders its OWN roster copy as chip buttons (agentOptions_, pushed by the cockpit atFleetCockpit.mjs:1525/2811) — the redundant bottom picker the operator names as terrible UX. There is no cockpit-level "selected agent" state.The Architectural Reality
reconcileRoster(FleetCockpit.mjs) mutates record-grain —record.set(row)in place, one batchedaddfor joiners,removefor leavers. A store-driven list animates exactly these events; the ingest path needs no change.Neo.list.Component(component-based items with instance reuse viacreateItemContent— sibling patternsrc/calendar/view/calendars/List.mjs) +Neo.list.plugin.Animate(explicit component-list sort support). The plugin's blocking gaps (resize awareness, fluid item width, owner scroll height, reduced motion, component filter parity) are #17552 — this ticket is blocked by it.Neo.selection.ListModel— single-select,selectionChange,selectedCls, built-in item focus/arrow navigation — retiring the grid's hand-rolled machinery wholesale.stateProviderhostsstores.fleetRoster(FleetCockpit.mjs:246); the selected agent joins it as provider data so dock-projected panes (docked, vesseled, torn out) read one truth.FleetAgent:displayName(Brain-folded social name — the sample roster renders Clio/Ada/Grace/…),state(ok|idle|wedged|limited|off),participationStatus(operator_benchedis the authoritative bench fact),presence.lastSeenAt(plane axis; null while presence is unobservable — today's in-process mode). No latest-activity stamp exists — Fix 4.The Fix
FleetGridrebuilds onNeo.list.Component—animate: true,itemHeight= the measured 126px anatomy constant, fluid columns via #17552'sminItemWidth, preserving today's ladder (1-up ≤640, 2-up default, 3-up ≥1280 of grid measure; the container-query values move fromFleetGrid.scssinto the JS ladder input — ADR 0029 §2.6 pane-layout-blindness intact: the list measures only its own rendered surface).AgentCardbecomes the component item (create/reuse keyed by record, calendar-List pattern); its inner anatomy, controller and lifecycle events are untouched. The stable head (title · stale marker · presence-cap chip · HealthBar) stays the sibling above the list; the empty-fleet CTA renders outside the list while the store is empty.tierRankmodel field fromstate, then case-foldeddisplayName; continuity, not a behavior change),displayName),lastActivityAt, nulls last — "that would move a lot": the firework is real once the axis is stamped). Mechanics = the store's own sorters (examples/list/animatepattern:store.sorters[0].set({property, direction})).state === 'off'), hide benched (participationStatus === 'operator_benched'), and the density fold re-expressed as a filter preset: atfoldThreshold(≥12) the idle tier filters out by default while the head renders the honest count ("+N idle · show") toggling it — the fold row leaves the card flow (a non-record row cannot live in a store-driven list; the count moves to the head, same truth). Store filters → the plugin fades. Session-local, not persisted.lastActivityAt— stamped Brain-side, never view-derived: the roster DTO row (ai/services/fleet/fleetCockpitStatus.mjs, theopenLaneCountprecedent) gains a tri-statelastActivityAt(ISO string | null) stamped by the assembler from the newest attributable per-agent activity timestamp it already holds (activity-adapteroccurredAtfold;presence.lastSeenAtmerges in when the presence axis is wired).mapRosterRowpasses it through. Null = not stamped → tail of the sort, honestly (in today's bearerless dev mode every row is null and the sort degrades to name order — correct, not broken).selectionModelon the list; card click / Enter selects;selectionChange→ the cockpit writesdata.selectedAgentIdon its provider AND runs today's detail path (record →detailRecord→ reveal). The in-card lifecycle Buttons (toggle/restart) are carved out of the selection path — a control click never changes selection. The dedicated drill Button retires (the item itself is the target);agentSelectgoes with it.MemoriesPanedropsagentOptions_+ the chip row;activeAgentfollows the provider'sselectedAgentId(write-through replaces the pushes at 1525/2811). Null selection renders the existing "Pick an agent…" empty state — now satisfied by clicking a CARD: one picker for the whole cockpit. The pane keeps a one-line "for:KeyNavigationdrill jump,SEMANTIC_CHILD_REFS, and the gate-3 focus restore inrefreshGriddelete — native list item focus/arrow navigation + no-rebuild reconciliation replace them; their specs migrate to the list behaviors..fm-fleet-cards's CSS grid + container queries retire in favor of plugin geometry; token-only skin for the sort/filter cluster and the selected-card state (a--fm-signalring — selection is a state the theme owns).Contract Ledger Matrix
AgentOS.view.fleet.roster.List(new consumed view)Neo.list.Component;Neo.list.plugin.AnimatefleetRosterrecords render as stable AgentCard instances; sort moves and filters fade the same instancesFleetAgent.tierRankstate,displayName,participationStatusfieldsFleetAgent.lastActivityAt+ roster DTO field (new)occurredAtand observable presence timestampsnull, stamped before the view;mapRosterRowpasses it through unchangednull; the view never invents oneselectedAgentId/selectedAgentIdentity(new data keys)state.Provider; one-detail/one-memories product semantic@githubidentity and drives detail/memories consumersnull; no second picker truthNeo.selection.ListModelAgentOS.view.fleet.memories.Containertarget selectionagentOptionschip row; selected provider identity drives the request and a “for: name” label.fm-fleet-cardslist/card skinlistays a transparent zero-padding geometry carrier in every state; AgentCard owns background, selection and focus paintAcceptance Criteria
AgentCardinstances survive, witnessed by instance identity across the sort) — no rebuild, no flash.foldThresholdwith the honest head count and toggles live.selectedAgentId, reveals/updates the detail pane, and re-targetsMemoriesPane— whose chip row is GONE; a control-button click never changes selection.lastActivityAtis stamped Brain-side (tri-state), passed throughmapRosterRow, and the latest-activity sort orders stamped rows DESC with nulls last (unit witnesses producer-side and view-side).reconcileRoster's record-grain writes).npm run check-theme-surfacesgreen; tokens only.Out of Scope
MemoriesPanecontent design (#17268), the activity-stream buffered list (#17550), sort/filter persistence (cockpit presets),FleetCockpitdecomposition (#17335 — additions here stay lean and factored).Avoided Traps
lastActivityAtclient-side would create a second truth the producer cannot correct — the DTO owns the stamp end-to-end (theopenLaneCountprecedent; the model's own passthrough discipline).Related
list.plugin.Animate) and #17560 / PR #17566 (fleet topology).Decision Record impact: aligned-with ADR 0029 §2.6 (pane-layout-blind geometry — the roster measures its own rendered list surface and contains no dock/viewport-placement knowledge).
Live latest-open sweep: checked latest 20 open issues at 2026-08-22T15:51:54Z (
gh issue list --state open --limit 20); no equivalent found. A2A claim sweep at 15:52Z (latest 14, recency-bound): no competing claim; the adjacent #17550 lane-pick (Emmy) is a disjoint surface.Origin Session ID: 14acab5a-4b6c-4987-91c7-f683e39baa55 Retrieval Hint: "fleet roster animated list sort filter selection replaces memories chips"