refactor(members): move the Crew Members data layer onto React Query - #9442
Conversation
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. The ExecutionsView inline twin at First-Principles-Verdict: PASS Verify Not justified as shipped
What this change shipsIntent: make the Crew Members page render from cache on a return visit instead of blank-then-refetch — a FIX (issue #9418, mandated by
Watch
[FIRST-PRINCIPLES-REVIEWED] 3173bb6 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right shape: the page joins the repo's mandated React Query pattern, and the trust-sensitive thread-key cache carries explicit reconnect invalidation, re-confirmation, and pins. Suggestions
[DESIGN-REVIEWED] 3173bb6 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Every reworded notice survived the cold read — the blind reader understood each failure state and knew the next step without seeing the code. Watch
Suggestions
[UX-REVIEWED] 3173bb6 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate list contains no candidates, and my independent checks of the highest-risk new logic all hold:
Nothing survives to the 80+ bar, and I found no groundable Step-2 addition. No findings. [OPUS-REVIEWED] 3173bb6 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
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: |
|
Round 2 (pushing shortly) addresses every lane: Frontend Tests (4) — three MembersPage cases red, all real, all mine:
GPT F1 (errors-use-error-notice) — the four bare GPT F2 (star cache vs server) — fixed rather than overridden: the row's pending lock is now taken synchronously in Design Review (instant-mount of a cached thread key across a restart) — a dropped-then-restored socket is the one client-visible sign the gateway may have restarted, so: First Principles subtractions — |
541e86a to
16284b5
Compare
16284b5 to
c9ec2f8
Compare
|
Round 3 ( |
c9ec2f8 to
dfbea9e
Compare
|
Round 4 ( |
a9c78ce to
bfeef08
Compare
|
Round 7 ( |
bfeef08 to
be2417a
Compare
|
Round 8 ( |
Fixes #9418. The page held its roster, the drawer's activity and wake sources, and the thread-open answers in useState filled by useEffect, so every visit started from an empty list, waited on the network, and repeated every request. It now reads through React Query and writes through useMutation: - Roster: useQuery under ['kirocrew-agents', 'members-roster'] (30s stale) so a return visit renders the cached list at once and refreshes behind, and a crew written anywhere reaches it through the registry-prefix invalidation (WS refresh frames; the crew editor now invalidates the prefix instead of refetching only its own query). - Activity: useQuery per exact member name, drawer-gated. - Wake sources: the shared ['cron-jobs'], crewWebhooksQueryKey and defaultAgentQuery entries -- one fetch serves the editor, Schedule and this drawer, and their invalidations reach it. - Thread open: useMutation on every open (the endpoint is the idempotent creator/repairer), outcome cached per member under ['member-thread', name] and read through a skipToken query, so the cached thread mounts immediately while the re-POST repairs in the background. - Star: useMutation with a per-row optimistic patch and revert on the roster cache; cancelQueries first so an in-flight refetch cannot land on the optimistic row. Behaviour kept: default/remembered member, gone-member notice, needs_you and patrol badges, driving sessions, presence dot resolution. Tests: mocks moved to defaultAgent; star cases wait for the async onMutate; four new pins -- second mount renders the cached roster with no empty state and refetches behind; second mount mounts the cached thread without waiting on the POST; prefix invalidation refreshes in place; a refetch failure after a good read keeps the last roster.
be2417a to
3173bb6
Compare
|
Round 9 ( |
Fixes #9418.
Summary
The Crew Members page held its data in
useStatefilled byuseEffect— the roster, the drawer's Recent activity and Wake sources, and the thread-open answers — so every visit started from an empty list, waited on the network, and re-issued every request.website/AGENTS.md: "Data fetching is React Query, never useState + useEffect."This moves the page's data layer onto React Query, following the repository's existing key conventions:
GET /api/members)useState([])+useEffectuseQueryunder['kirocrew-agents', 'members-roster'],staleTime30s (newwebsite/src/api/membersQuery.ts). Lives under the registry prefix so the WSrefreshinvalidation and the crew editor's save reach it.useStatemap +useEffectuseQuery(['member-activity', slug, name]), drawer-gatedPromise.allinuseEffect, never retried after a failure['cron-jobs'],crewWebhooksQueryKey,defaultAgentQueryentries — one fetch serves the crew editor, Schedule and this drawerPOST …/thread).theninto aslotsmapuseMutationon every open (the endpoint is the idempotent creator/repairer), outcome cached under['member-thread', name]and read through askipTokenquery — the cached thread mounts at once, the re-POST repairs behindsetMembers+.catchrevertuseMutation: the row is locked synchronously on click,onMutatecancels in-flight roster refetches then patches the row;onSuccess/onErrorcancel again (a refetch started during the PUT could otherwise land its pre-write snapshot after the final patch) and then re-apply the confirmed / reverted valueOutside the page:
KiroCrewAgentsPageinvalidates the['kirocrew-agents']prefix after a registry write instead of refetching only its own query, so a crew created or deleted there reaches the roster (its own query is active, so it still refetches exactly as before).useWebSocketforgets every unobserved['member-thread']entry on reconnect (forgetUnobservedMemberThreads, counting observers — the page reads the open member through askipTokenquery, which react-query classifies as inactive, sotype: 'inactive'would have cleared the mounted pane too; unit-pinned inmembersQuery.test.ts): a dropped socket is the one client-visible sign the gateway may have restarted (which drops an unmessaged member slot while its binding survives), so the next open of those members waits for the endpoint again, as before this PR; the page re-confirms the observed one on the same reconnect. (Their idle lifetime is react-query's defaultgcTime; the sweep is the control.)cronJobsQuerydefinition (website/src/api/cronJobsQuery.ts) for['cron-jobs']. ExecutionsView keeps its identical inline spelling for now: migrating it removed a covered statement from a coverage-baselined file and tripped the per-file floor by 0.1% — the baseline is shrink-only by design, so that move waits for the file to gain tests.role="alert"boxes this page had (roster, thread failure, activity, wake) and the slug-collision notice all render throughErrorNotice(AUTOSDEerrors-use-error-notice).One defect found by the first CI round and fixed here: the roster being a store means the thread endpoint confirming a key (which patches the row) can re-render on React's sync lane before the default-lane
setActiveNamefrom the open commits; the URL-sync effect then re-ran with the old name and opened the same member twice (and, for a stand-in open, overwrote the remembered member). The effect now guards on a refactivatewrites synchronously (activeNameRef); the two pre-existing cases that caught it are the regression pins.Behaviour kept: default / remembered member (#8546), gone-member notice,
needs_youmarker (#8614), auto-patrol badge (#8936), driving sessions (#8591), presence-dot resolution — all now render from cache immediately on a return visit.While the roster read has failed the member count shows a dash rather than "0 members" above the failure (UX review), and
slug_collisionnow reads "shares its short name with {{name}}, whose conversation already uses it. Rename one of the two members…" — plain words, and this page's vocabulary is members (UX review; 12 locales).One new string (12 locales + regenerated en-XA):
pages.membersPage.thread_repair_failed— "Couldn't reconnect this conversation. Reopen the member to try again." — shown when the repair POST fails over a cached, still-mounted thread, instead of "Could not open this member's conversation" above a visibly open conversation (UX review). It makes no claim about the messages below, which may be none.Screenshot Evidence
Both recordings: land on the dashboard home → open Crew Members → leave through the router (SPA back, never a reload) → open Crew Members again. The second open is the one under test.
Before (
main@6fbb06bcde845731aacf179f32398782d78ba041): the return visit shows 0 members / Pick a member, then the list, then Opening the conversation…, then the drawer skeletons.After (this PR): the return visit renders the roster, the thread and the drawer on the first frame and refreshes behind.
Decisive frames (return visit, ~0.5 s after the click): before · after.
Error states (the four surfaces moved onto
ErrorNotice, three with Ask the agent): a return visit whose repair POST fails over the cached, still-mounted thread — now worded as a failed reconnect, not a failed open — with the drawer's activity and wake-source reads failing on a cold open. Produced by aborting those three requests at the browser (page.route→abort, i.e. the fetch rejects exactly as on a dropped connection); everything else is the pod's real response.While a repair has failed, the pane suppresses its own "Session ready. Type a message to start." hint (new optional
hideEmptyHintprop onChatPane), so the column does not say "go" one line under a notice that says "broken". The cold-open failure — no cached key, empty column, so no draft to lose — keeps the original "Could not open…" copy and offers Ask the agent (askAgent={!activeSlot}; the hand-off stays off only while a cached thread is mounted underneath):The other two
ErrorNoticesurfaces: the roster load failure (GET /api/membersaborted at the browser on a cold open) and a real slug collision — the pod is seeded with two crews,oncallandOncall, that share one slug;oncallis opened first and binds the thread, then openingOncallmakes the endpoint answer with the other owner and the page refuses to mount (no stub):Provenance: recorded from
3173bb61f8c358b03070cac5249d645507b3eb46(after — the bundle was rebuilt from this exact tree before recording; earlier revisions of this PR had re-runpod provision, which skips an existing dist, so their "after" GIF came from the round-1 bundle — same flow, but the provenance line overstated it, corrected here) and6fbb06bcde845731aacf179f32398782d78ba041(before, the merge base) · twokirocrew podinstances built from those checkouts, seeded with a six-crewconfig.json(the pod's agent sync added the package crews) · mode:GET /api/membersandPOST …/threaddelayed 1200 ms throughpage.routeso the localhost round-trip is visible at 12 fps — responses are the pod's real responses, only later; no data is stubbed · real server, no model turn is involved in this flow · Playwright webm → PNG frames → GIF (Pillow, global palette + per-frame differencing, so each GIF is under 1 MB and loads inline), verified frame-by-frame on the encoded GIFs: the 0 members frames appear in the "before" return visit only. Both pods were re-seeded before this recording and the light theme pinned in the scenario so the pair is like for like.Requests issued during the second open (captured by the same Playwright run,
page.on('request'),/api/*only):GET /api/members(roster)GET /api/crons,GET /api/webhooks,GET /api/agents(wake sources)GET /api/members/{slug}/activityPOST /api/members/{slug}/threadPOST /api/agents/sync+GET /api/agents(agent sync, not this page)Tests
Written, not run locally (this branch's rule is tsc / eslint / i18n gate only; CI runs vitest):
MembersPage.test.tsx: mocks moved fromkirocrewAgentstodefaultAgent; four new pins under roster cache (React Query) — a second mount renders the cached roster synchronously with no empty state and, inside the 30 s stale window, issues no request; a second mount mounts the cached thread while the re-POST hangs; invalidating['kirocrew-agents']refreshes the roster in place; a refetch failure after a good read keeps the last roster.MembersPage.test.tsxalso pins the repair-failure copy: a failed re-POST over a cached thread keeps the thread mounted and says "Couldn't reconnect…", never "Could not open".MembersPage.filters.test.tsx: star cases wait for the asynconMutate(the flip and the PUT now sit a microtask after the click, behindcancelQueries); the pending case additionally pins that a 2xx star does not refetch the roster.Local:
tsc -bclean, eslint clean on the touched files,check-i18n-stringsat baseline, phantom-classes gate green; the two test files type-check under a vitest-globals tsconfig.Pattern harvest
The Members page was hand-built as a sibling of the Sessions sidebar rather than a consumer of its list infrastructure. In one week that surfaced as five independent drift fixes — steer-only composer (#8852), Quote/Ask (#8947), drag-handle / surface parity and list-shell background (#9428), and now data fetching (#9418). Each was found by a PM reading the shipped page, because there is no shared contract to drift from. Follow-up: #9441 — make the roster reuse the sidebar's list shell, row primitive and data layer.
Notes for reviewers
POST …/threadis the only creator/repairer of member slots and must run on every open; the cache decides what renders while it is out, it never replaces the POST. Storing the outcome under a query key (written bysetQueryData, read by askipTokenquery) is what makes the cached thread mount instantly on a return.mainas the base.