fix(dashboard): one list-shell recipe for sessions sidebar and members roster - #9428
fix(dashboard): one list-shell recipe for sessions sidebar and members roster#9428CrysisDeu wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The refactor is faithful and well-shaped: sidebar classes moved (not re-typed) into Design-Verdict: PASS Root-cause fix for value-drift — shared recipes pinned by literal-forbidding parity tests — with kept differences named and the extraction absorbing the oldest copy. [DESIGN-REVIEWED] 810ea8a |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I have what I need. Permission to fetch issue #9416 was declined, so I'll note the unverifiable provenance where it matters and finish the review with what the diff and repo establish. Counts I ran: First-Principles-Verdict: CONCERNS Live-state filters and sort ride the drift fix on symmetry alone — "the roster's counterpart to the sidebar's session filters" — no quoted report asks for them. Not justified as shipped
What this change shipsInventory (10 items, capped — SessionFlyout title, doc rows, i18n omitted) — 4 justifiedIntent: make the Crew Members roster and the Sessions sidebar look and behave as one surface (reported drift) — a FIX.
WatchItems 6–7 rest on the symmetry form of an inherited requirement: the module comment justifies them as "the roster's counterpart to the sidebar's session filters", and the description names no report or failing user for either — while they add two persisted storage keys ( Subtractions
[FIRST-PRINCIPLES-REVIEWED] 810ea8a |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS Blind reader navigated every primary control correctly; the only friction is two filter labels whose meaning lives solely in hover tooltips, and one unshown badge state. Watch
Evidence gaps
[UX-REVIEWED] 810ea8a |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
e6a38f8 to
06be820
Compare
06be820 to
a3c8f53
Compare
a3c8f53 to
aace964
Compare
|
self-added: no
Disposition: needs-a-decision — sixth head, same answer; ruling stands as recorded in #issuecomment-5593381060.
|
|
self-added: no
Disposition: needs-a-decision — sixth head, same answer; ruling stands as recorded in #issuecomment-5593383467.
|
55f5c9b to
c171717
Compare
c171717 to
c38a5a6
Compare
|
self-added: yes
Disposition: fixed in c38a5a6 — the roster chip now wears the sidebar's neutral clear-all shape.
|
|
self-added: no
Disposition: needs-a-decision — ruling stands as recorded in #issuecomment-5593837261; the maintainer names the words.
|
|
self-added: no
Disposition: needs-a-decision — seventh head, same answer; ruling stands as recorded in #issuecomment-5593381060.
|
|
self-added: no
Disposition: needs-a-decision — seventh head, same answer; ruling stands as recorded in #issuecomment-5593383467.
|
|
self-added: no
Disposition: fixed in aa7cb83.
|
|
self-added: no
Disposition: needs-a-decision — ruling stands as recorded in #issuecomment-5593837261; the maintainer names the word. |
|
self-added: no
Disposition: rebutted — ruling stands as recorded in #issuecomment-5593837437.
|
|
self-added: no
Disposition: needs-a-decision — eighth head, same answer; ruling stands as recorded in #issuecomment-5593381060. |
|
self-added: no
Disposition: needs-a-decision — eighth head, same answer; ruling stands as recorded in #issuecomment-5593383467.
|
|
self-added: yes
Disposition: fixed in 87fdc83.
|
|
self-added: no
Disposition: fixed in 87fdc83.
|
|
self-added: no
Disposition: needs-a-decision — ruling stands as recorded in #issuecomment-5593837261; the maintainer names the words. |
|
self-added: no
Disposition: needs-a-decision — ninth head, same answer; ruling stands as recorded in #issuecomment-5593381060. |
|
self-added: no
Disposition: needs-a-decision — ninth head, same answer; ruling stands as recorded in #issuecomment-5593383467.
|
|
self-added: no
Disposition: rebutted — ruling stands as recorded in #issuecomment-5593837437.
|
|
self-added: no
Disposition: needs-a-decision — ruling stands as recorded in #issuecomment-5593837261; the maintainer names the words (one key each, 13 locales). |
|
self-added: no
Disposition: needs-a-decision — tenth head, same answer; ruling stands as recorded in #issuecomment-5593381060. |
|
self-added: no
Disposition: needs-a-decision — tenth head, same answer; ruling stands as recorded in #issuecomment-5593383467.
|
…mbers roster The Crew Members roster is the Sessions sidebar's twin, built from copied values that drifted: in kiro-light its card stayed white (#fff) while the sessions card stepped back to --panel (#fafafa), its header sat on its own padding (16/16/4 vs an 8px 40px line), its row titles were 500 not 600 with unset line-heights, its column-resize grip was a flat 6px strip where the sidebar draws a 2px accent pill, and its search field carried a chip row where the sidebar docks a sort/filter menu in the field (#9416). components/listShell.ts now carries the sidebar's recipes — card (with the `sidebar` theme-pack hook and the `sidebar-inner` kiro-light hook), header line, title, list body, row box and states, row type scale — and both pages consume them; SessionFlyout's title copy joins in. ResizeHandle renders the sidebar's pill (2px rounded bar, accent on hover/focus, accent-hover while dragging, `inset` for the card radius, `className` for absolute placement) and keeps its window-splitter keyboard contract; the sidebar mounts it and gains arrow-key resize, the roster mounts it on the card border with the same 12px inset. components/SearchFilterBar.tsx is the sidebar's search row (field, clear button, trailing 24px filter trigger with badge, the padding table that keeps text clear of it); the sidebar mounts it and the roster replaces its SearchInput + chip row with it, the menu holding star, live state (working / needs you / unread / patrolling), origin and sort. The roster filter model is pure (pages/members/rosterFilter.ts) so the page's state layer can move without touching how a filter decides. New labels in all locales. Parity tests pin both pages to the shared recipes and components and forbid the literals; the kiro-light hook test follows the hook into listShell and requires both cards to mount it; rosterFilter has its own unit pins. Closes #9416
Closes #9416
What
The Crew Members roster and the Sessions sidebar are now built from one set of recipes — same card (and the same theme hooks), same header line, same title, same list body, same row box, same row type scale, same column-resize grip on the card's border, and the same search row with the sort/filter menu docked in the field. Three of the drifts were reported by an insider ("这个 drag handle 和 session 页面的不一样", "crew list 的背景和 session list 背景颜色不一样", "crew member 的搜索 bar 能不能和 session 的看齐(把 filter 集成进去)"); the audit found the rest.
Sidebar look wins throughout (older, what users know). Where the roster keeps a difference, it is named below with the reason.
Measured before → after
Playwright
getComputedStyleon two pods (base =main@ 6fbb06b, fix = this branch), 1440×900, idle row. Full JSON in the PR's capture run; the user-facing numbers:--panel)text-texttext-mutedtext-textbg-bg-hover/bg-accent-subtlebg-accent·bg-accent-hover· 12pxbg-accent/30· none · nonebg-accent·bg-accent-hover· 12pxboundingBox)SearchInput+ clear button + 24pxListFiltermenu trigger docked in the fieldSearchInput, no clear button, a wrapping row of 4 filter chips under itKept differences, by design: the roster keeps its "N members" count line under the header (the sidebar's header has no count); the roster header leads with the page icon (it names a page, the sidebar title sits next to the rail toggle); rows are two lines (name + last message) where session rows carry a third meta line; presence rides the avatar and unread sits on the row's right edge (IM convention) instead of leading the secondary line.
How
components/listShell.ts(new) —LIST_SHELL_CLS(sidebar sidebar-inner bg-bg-elevated border border-border rounded-xl shadow-sm: thesidebartheme-pack hook and thesidebar-innerkiro-light hook from feat(theme): white chat canvas with grey shell in kiro-light #8711 lead the string),LIST_HEADER_CLS,LIST_TITLE_CLS,LIST_BODY_CLS,ROW_BOX_CLS,ROW_IDLE_CLS,ROW_ACTIVE_CLS,ROW_META_CLS/ROW_TITLE_CLS/ROW_STATUS_CLS. These are the sidebar's own class strings, moved, not new values.pages/ChatSidebar.tsx— consumes all of the above where it used to spell them (root card, header, title, both lane bodies, the session row's box/idle/active classes, the three type constants). Its inlined grip is replaced by<ResizeHandle inset={12} className="sidebar-resize-handle absolute top-0 -right-[3px] h-full z-10" …/>plusnudgeSidebarfor arrow keys (same clamp + persist a drag applies)..sidebar-resize-handlestays as the hook the mobile overlay and split-pane host use to hide it.pages/members/MembersPage.tsx— the roster card, header, list and rows consume the same recipes (cn()mergespr-8over the box'spr-3for the star). The grip moves from an in-flow flex column between the cards onto the roster card's right border (absolute, same 12px inset); the roster→thread gutter goes from 22px to the plaingap-2, andTHREAD_MIN_RESERVEfollows. Label becomes an action,pages.membersPage.resize_roster("Resize member list"), in 13 locales + regenerateden-XA.pages/chat/SessionFlyout.tsx— its copy of the title recipe becomesLIST_TITLE_CLS.components/SearchFilterBar.tsx(new) — the sidebar's search row:SearchFilterBar(field, clear button once there is text, trailing-controls slot, and the sidebar's padding table — 36/56px with one/two 24px controls, +20px with text — so typed text never runs under the buttons) andFilterMenuButton(the 24pxListFiltertrigger with the capped corner badge,forwardRefforDropdownMenuTrigger asChild), plus the menu's label/content class recipes.ChatSidebarmounts both and drops its inlined copies; the roster replaces its bareSearchInput+ chip row with them.pages/members/rosterFilter.ts(new) — the roster's filter/sort model as pure functions over the member array (sortRoster, the order-preservingnarrowRoster,matchesStatus,countByFilter,queryNarrows, the storage parsers), plusmatchesSource/parseSourceFilter, moved here fromMembersPage(nothing else imported them; the one test that did now importsrosterFilter). The page hands in aRosterQueryand asignalsOf(member)resolver for the live facts (running from the slot frames, needs-you fromtabStatus= permission | question, unread, active patrol) — nothing in the model reads React state or storage, so the data layer can move to React Query underneath without touching how a filter decides.pages.chatSidebar.filter_running/filter_unread), so the same slot state cannot read as two words across the two menus. ORIGIN: Mine / Built-in / From packages (radio rows, choosing the active one clears it — the old chip semantics). SORT BY: Recent activity / A → Z (the A–Z row reuses the sidebar'spages.chatSidebar.sort_name_asc; the activity row keeps a member-specific word because the sidebar's "Newest" reads as "newest member" on a list of people). Status set and sort persist undermc-members-status/mc-members-sortnext to the existing star/origin keys; the trigger's badge is the unread count — the same meaning the sidebar's trigger badge carries. New keyspages.membersPage.{sort_filter_members, filter_origin, filter_status_needs_you, filter_status_patrolling, filter_status_*_description, sort_recent, sort_name}in 13 locales + regenerateden-XA; the Patrolling tooltip is in task terms ("Members running a scheduled check-in loop").components/ResizeHandle.tsx— renders the sidebar's visual: 6px transparent hit strip (group/drag) carrying a 2pxrounded-fullbar,group-hover:bg-accent/group-focus-visible:bg-accent/group-active:bg-accent-hover,resize-accentso the Kiro dark theme's retint applies. New propsinsetandclassName(tailwind-merged). Keyboard/ARIA contract unchanged; Issue Radar and Projects consumers inherit the look.index.csscomment +docs/theming-contract.md— thesidebarhook row now names both cards and the module that carries the hook.Tests (written, not run locally — see below)
test/listShellParity.test.ts(new) — both pages import and use every shared recipe; neither spells out any of the replaced literals (the shell string, header, title, body,'text-text-strong bg-accent-subtle',text-[13px] font-medium truncate, the three leading pairs); the recipes carry the sidebar's exact values; the Notes rail's inlineRAIL_TYPEstill mirrors the type scale.test/ResizeHandle.parity.test.tsx(new) — both pages mountResizeHandleand hand-roll noaria-orientation="vertical"; the bar recipe,inset→calc(100% - 24px),classNamemerge, onNudge/tabIndex contract.test/kiroLightShellHooks.test.ts— thesidebar-innerhook is now pinned incomponents/listShell.ts, and a new case requires bothChatSidebar.tsxandMembersPage.tsxto mountLIST_SHELL_CLS(and forbids the old literal), so the kiro-light step-back cannot reach one card and not the other again.MembersPage.test.tsx— the DetailPanel separator assertion narrowed to/resize panel/i(the roster grip is now a second/resize/iseparator on the page).test/searchFilterBar.parity.test.tsx(new) — both pages import and mountSearchFilterBar+FilterMenuButton; neither re-inlines the list search field, the trigger markup or the roster chip row; the component contract (clear button appears with text and clears throughonChange, the 36/56/+20 padding table, clear-button offsets, badge cap at 99+).pages/members/rosterFilter.test.ts(new) — every dimension without a DOM: search, star, origin buckets, each status alone, OR across statuses, AND across dimensions, sort applied to filtered rows, counts, and the storage parsers rejecting junk. Expectations were dry-run against an esbuild bundle of the module.MembersPage.filters.test.tsx— the chip interactions become menu interactions (open with Enter on the trigger, as the sidebar's own filter tests do;aria-pressed→aria-checked); new cases for the status rows (OR, persistence, filtered-out notice), the sort rows, a persisted sort on mount, and the shared search row's clear button and trigger.Mutation-verified by inspection: re-inlining any listed literal fails the negative pins; dropping a recipe's use fails the positive ones; dropping
rounded-fullor a hover/active class from the bar fails the recipe pin.Screenshot Evidence
Two isolated pods, 1440×900 @2x. Left pair BEFORE, right pair AFTER; within each pair Sessions sidebar then Crew Members roster.
Light — the two cards side by side (BEFORE: roster white on the white canvas, sessions grey; header/title/list/row metrics differ. AFTER: same card colour, same header line, same row type.)

Dark — the two cards side by side

Search row + filter menu open, dark (BEFORE: roster has a bare field and a wrapping chip row; AFTER: the sidebar's field, clear button and docked menu on both, the roster's menu holding star / live state / origin / sort — full height, through SORT BY)

Grip — rest, hover, active (dragging) — both panels, AFTER (the BEFORE roster grip is the flat strip measured in the table above; these replace the narrow crops round 7 called unreadable.)


Grip, light, top corner ×4 — the 12px inset spans exactly the straight segment of the roster card's border, as on the sidebar

Search row with text typed — the clear button, both panels, light + dark

Filters on, menu closed — the at-rest marker round 6 asked for (ONE aggregate chip in the sidebar's chip recipe — the shared


FilterChip, which the sidebar's per-filter pills also render — whose visible text is the click's outcome — "Clear Starred (2), Mine (6) filter", the same sentence its accessible name carries, so nobody has to guess whether the one ✕ drops one filter or all. It wears the sidebar tag filter's NEUTRAL clear-all shape, not a filter colour: on both lists a coloured pill clears one filter, a neutral pill clears several. One control, not one per filter: AUTOSDE max-two-buttons-per-row.)Origin row checked — "Mine ✓" with Starred, the state the chip below names

Status rows checked — lit glyphs and checks ("In progress" and "Unread" on: the Zap fills in its warn colour, the Unread dot fills accent; a checked row keeps its count.)

Filters applied — the states round 5 asked to see (Starred checked: lit star, check, every row's count right-aligned like ORIGIN's, 0 included; the count line reads "2 of 14 members"; a zero-count status on top empties the list into the "Clear filters" notice.)



More: light search row + menu, light grip hover, active (mid-drag) states, dark top corner
Pattern harvest
The Members page mirrors the chat surface, and every mirror drifts where it re-implements instead of reusing. Same family as #8852 (steer-only composer) and #8947 (Quote/Ask). This instance has two shapes of the same failure:
ResizeHandlehad been extracted from the app workspaces, not from the sidebar, so "shared" still meant "not the sidebar's". When a primitive is extracted it should absorb the oldest inlined copy, otherwise the extraction manufactures a second convention.Rule candidate: when a page is built as a mirror of an existing surface (the Members roster of the Sessions sidebar, a pane of the chat page), it must import that surface's class recipes and components rather than copy their values — and a parity test naming both surfaces must forbid the literals — because a copied value cannot receive a later change to its source (here #8711's kiro-light hook). Corollary for extraction: when a primitive is pulled out of one surface, absorb the OLDEST inlined copy of it in the same PR, or the extraction manufactures a second convention.
Follow-ups (not in this PR)
ResizeHandle(one-liners now thatclassName/insetexist):apps/code-review-sage/Workspace.tsx(Splitter),apps/spec-builder/components/ColumnSplitter.tsx,apps/spec-builder/components/SpecDetail.tsx,pages/WebhooksPage.tsx;components/DetailPanel.tsx(left edge, flush — needs anedgeprop).ChatSidebarhistory-pane splitter,SessionGridLayoutdivider,ChatInputcomposer grip — want a horizontal variant of the same recipe.apps/md-notebook/constants.tsRAIL_TYPEmirrors the row type scale by value (it is styled inline); pinned by the parity test, still a copy.Review round 1 (on
aace964b7)MembersPage.test.tsxroster-order selector followed the title weight (.font-medium→.font-semibold); zh-CN terminology (回合→轮次for "turn"); hi tone (formalआप→तुमin the three new strings).matchesSource/parseSourceFilterre-export dropped (the one importer,MembersPage.filters.test.tsx, importsrosterFilterdirectly);styleprop dropped fromResizeHandle;EMPTY_QUERYexport dropped (the test builds its base query). The Watch item on the status/sort dimensions is dispositioned in a comment: they are the user's ask, relayed by the conductor ("filter 菜单里放成员相关维度:状态 … crew 归属 … 排序"), not symmetry-inherited.Review round 2 (on
5997f9877)pages.chatSidebar.sort_newest/sort_name_asc) instead of "Recent activity" / "Name", so one ordering has one name in both menus. The filters-behind-the-funnel item is the insider's ask ("把 filter 集成进去") and keeps the counts visible, as the review accepted. The lit unread badge is not screenshotted: it is the sidebar's own badge markup on the sharedFilterMenuButton, exercised bysearchFilterBar.parity.test.tsx(count and 99+ cap), and a pod has no unread member without a live agent turn — dispositioned in a comment.Review round 3 (on
96cb0a3d9)FILTER | Starred | In progress | Needs your approval or answer | Unread | Scheduled check-ins | ORIGIN | Mine · Built-in · From packages | SORT BY | Recent activity | A → Z.Review round 4 (on
c1ed4ef4b)Review round 5 (on
890dc4c82, after the rebase onto main @50cc659de)MembersPage.tsxwas resolved by keeping main's data layer (slotKeyOf,threadOutcome, theloadErrordash) under this PR's filter model (the filter memo is nowrosterFilterQuery, apart from main'srosterQueryread).Appover its ceiling by 67 B andtby 66 B. Measured: main's own tip builds them 373 B / ~520 B under the ceilings set on 2026-09-04, and this PR adds ~440 B / ~590 B (three small shared modules, ten catalog strings). Both entries re-based on the new measurement with the same 5% convention, incheck-bundle-size.mjs, with the measurement in the comment.INPUT_PAD_RIGHT/CLEAR_RIGHTinSearchFilterBar.tsxare three entries (no mount reaches a third trailing control). Items 7–9 (status filters, sort, badge) stay: their provenance is the user's relayed request, dispositioned on the thread for this head.Review round 6 (on
b8aec806d)5f2202429: main re-measured theAppceiling itself (chore(bundle): re-measure the App chunk ceiling (3360 -> 3530 KB) #9523, 3530 KB), so this PR now carries only thetentry's re-base; theAppline is main's.20df2d7c3: main also re-measuredt(fix(ci): re-measure the drifted t-chunk bundle ceiling #9529, 815 KB), socheck-bundle-size.mjsis now off this PR's diff entirely. Main's fix(members): commit the roster display order per membership, not per refetch #9537 commits the roster display order per membership (a refetch never re-sorts rows under the cursor); this PR keeps that and adds the chosen sort to the commit key —orderedMembersre-sorts only when membership or the sort changes, and the filters narrow that committed order through a new order-preservingnarrowRoster(pinned inrosterFilter.test.ts) instead of re-sorting.FilterChip+FILTER_CHIP_ROW_CLSincomponents/SearchFilterBar.tsx; the sidebar's inline pill became that component (identical classes and colour recipe), so both lists draw the chip from one place.searchFilterBar.parity.test.tsxpins both pages to it and pins the pill's contract;MembersPage.filters.test.tsxcovers show / clear / not-a-chip-for-search. Screenshots above.Review round 7 (on
ae5e3e5c1)FilterChipnaming every active filter ("Starred (2), Mine (6)") whose click clears them all — the same shape as the sidebar's tag filter chip, for the same rule. Test re-pinned: exactly one button in the row.ResizeHandle's docstring now states the claim covers the surfaces that mount it. Design suggestion (split the filter/sort additions into their ownfeatcommit): the repo's PR Hygiene gate requires one commit per PR.filterRosterhad no consumer outside its own test — deleted; the test composessortRoster+narrowRosterthe way the page does. The false "re-exported from MembersPage" sentence above is corrected. Items 8–10 (status filters, sort, chips/badge): fifth head with the same item — see the disposition; this is the maintainer's call.c190fbe5a(+6, no conflicts).Review round 8 (on
55f5c9b72)test/test_work_ledger.py:1403failing on a Windows sharing violation / "Event loop is closed" — main's own run ata1db7b25ffails the same shard, so it is inherited, not re-run. Rebased onto main @f036ab246for a fresh round instead.Review round 9 (on
c171717aa)i18n-rendergate's[vs-base]compare reporting 26 new findings on theapp-detailsurface (AppDetailPage.tsx, fixture description text) — a surface this PR does not touch, on a diff that passed the same gate on the previous head; the surface settles on a 400 ms timer, so base and head sampled different render states. Re-ran that one job.FilterChipnow has anaggregatevariant with the sidebar tag filter's neutral chrome, and the roster chip uses it, so the shapes say what the click does: coloured = this filter, neutral = all of them. Screenshots of the chip and the applied-filter states recaptured.ab3a5ac8d(+4, no conflicts).Review round 10 (on
c38a5a6a6)app-detail[vs-base]red was the settle-timer flake it looked like). The three reds are one family: Backend Tests (3.12, 4) hung at 99 % until the 40-minute job timeout, Backend Tests (Windows) (4) failedtest/test_work_ledger.py:1403on a Windows sharing violation, and Coverage Gate cascaded from the cancelled shard. Frontend-only diff; main's own latest run passes both shards. Not re-run — rebased onto main @d4e592979for a fresh round.Review round 11 (on
aa7cb83b0)test_work_ledgerhang and Windows sharing violation did not recur). The one red is again the E2Ei18n-render[vs-base]compare onapp-detail— identical numbers to round 9 (44 → 70, +26) on a surface this PR does not touch, green on the head in between; the settle-timer race in the harness, not a defect in this diff.clear_named_filterkey with one{{var}}, no new strings. UX evidence gap (taken): a menu shot with an ORIGIN row checked ("Mine ✓") added above, from the same fixture.705541178.Local gates
tsc -bclean,eslinton every touched file clean,i18n-check/check-i18n-keys/check-i18n-stringsOK. Vitest deliberately left to CI per this session's rules; the parity assertions were dry-checked against the sources with a node one-liner.