DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/loomworks-list-loading-cr-2-frontend-load-more-v0_5.html

Loomworks list-loading — CR-2: frontend load-more + page size — v0.5

Document: loomworks-list-loading-cr-2-frontend-load-more-v0_5 Version: v0.5 Status: BUILT at all buildable surfaces (engagements + six roomless deferred); branches not yet pushed — see Build status. Not a regression source for the remember-about-me confirmation-loop bug (diagnosed separately). Date: 2026-06-27 Supersedes: loomworks-list-loading-cr-2-frontend-load-more-v0_4 (2026-06-27), …-v0_3, …-v0_2, …-v0_1 Queue item: Companion build queue Item 1 — list-loading (cross-cutting pagination), frontend half Handoff: loomworks-list-loading-cr-drafting-handoff-v0_1 Step-0 source: loomworks-list-loading-cr-2-step0-report-v0_1 (live OL + merged-engine inspection — build CR-2 steps from THIS) Engine contract: CR-1 merged at eeae9c2 / alembic 0096 — the ten endpoints return limit + cursor + has_more + total_count + next_cursor Repo: loomworks (Operator Layer) — branch off current feat-companion-surface 17263c9. Engine touched only for the lists.page_size setting plumbing (one KNOWN_SETTINGS entry + one _LABEL_TO_KEY synonym set). Two-role note: This CR is scoping/drafting. CC executes. Nothing commits or pushes without the Operator's explicit authorisation — explicit-path staging, per-step commits, suite green at each step, push on separate authorisation.


1. What this CR does

CR-1 brought ten engine endpoints to one keyset contract. CR-2 builds the frontend that consumes it: a reusable explicit load-more control, applied to the list surfaces that exist today, plus the per-surface page-size defaults and the voice-adjustable global override.

Build status (2026-06-27)

Step State
1 — engine lists.page_size Built. Nullable bounded [10,100] setting on the three-place pattern; shared numeric tune path made None-safe (zero regression); orientation catalog 3→4. Engine branch list-loading-cr-2-page-size @ e5bbf05 (main eeae9c2 untouched, not pushed). Engine suite 3041/0/63.
2 — reusable spine Built. lib/pagination.ts + hooks/usePagedList.ts (append/prepend, dedup, caller-resolved pageSize) + components/lists/LoadMoreControl.tsx (explicit, has_more-gated, count always shown). 12 isolation tests. OL branch list-loading-cr-2-frontend-load-more @ 5973ff8 (off feat-companion-surface, not pushed). OL suite 390/76.
3 — wholesale surfaces Complete at two of three surfaces; engagements deferred (structural blocker). ✅ renders (4eb517b, suite 395, 5f/28t, D-4 land-on-oldest). ✅ Memory/assertions (d7fa2b6, suite 399, 5f/25t — two lists over one state-filtered endpoint). ⛔ engagements (home "Your engagements") — deferred, cannot paginate without an engine visibility projection (see §5a).
4 — home lenses re-wire Built (e3f401d). needs_you + recent stop discarding next_cursor, wired to the spine; applySettingChange lists.page_size branch (useConversation.ts:87-89) — the third settings place.
5 — per-surface defaults + override Built (5406c71, with Step 4 in one commit). Defaults: needs_you 15, recent 40, Memory held/settled 15, Rendering 15 — each useListsPageSize() ?? <default>, effective size in resetKey. Override uniformises + clears, test-pinned (lists-page-size.test.tsx); store coerces garbage → null. OL suite 409, +1 test file, existing mocks untouched (store test-safe by design). All three settings places confirmed wired end-to-end.
4 — home lenses re-wire Built (e3f401d). needs_you + recent stop discarding next_cursor, wired to the spine; applySettingChange lists.page_size branch (useConversation.ts:87-89) — the third settings place.
5 — per-surface defaults + override Built (5406c71, with Step 4). Defaults needs_you 15 / recent 40 / Memory 15 / Rendering 15 via useListsPageSize() ?? <default>; override uniformises + clears (test-pinned); store coerces garbage → null. All three settings places wired end-to-end.
6 — CenterPane beforecursor Built (20dcaf7 + count-suppress 376b098). One real call site (useConversation.ts; ChatView.tsx never sent before); prepend-mode spine adoption; R2 boundary-tie proven engine-handled; count suppressed on conversation. OL suite 415.

Why 3–6 are split. Steps 3–6 collectively touch well over 30 existing tests — each surface is embedded in parents whose tests mock the fetch adapters, so rewiring one fetch contract cascades mock updates across that surface's test and every embedder. The CR's >30-test halt threshold fired during the build; CC stopped at a clean boundary (both repos green, spine proven, nothing half-wired). Steps 3–6 proceed as bounded per-surface sessions against the built spine, each committed separately, each under the threshold: renders, Memory/assertions, engagements (Step 3's three surfaces, each its own session), home+defaults (Steps 4+5), CenterPane (Step 6). The spine — the high-value, hard part — is done; each remaining step is a surface-wiring.

Scope is set by what is live. Step-0 D found only 4 of the 10 endpoints have an Operator Layer surface today (engagements, assertions/Memory, renders/Rendering, conversation-history/CenterPane). The other six (considerations, compositions, shape-events, assertion-groups, manifestations, shapings) have no frontend consumer — their rooms are empty stubs (RoomView.tsx:37). CR-2 paginates what is live and defers the six roomless endpoints to a bounded follow-on: they get the load-more control when their rooms are built, not before. Building pagination for a surface that does not exist would be building against nothing.

Five pieces: 1. A reusable load-more control + paged-list state — explicit (not infinite scroll), has_more-gated, total_count surfaced, parameterised by item-key. 2. Applied to the 4 live endpoint surfaces (three swap wholesale limit=200 → cursor; CenterPane migrates beforecursor). 3. Home lenses re-wired to stop discarding next_cursor. 4. Per-surface page-size defaults. 5. lists.page_size voice-adjustable global override (nullable; the three-place settings extension).


2. The two decisions the Operator confirmed at drafting

Defer the six roomless endpoints. CR-2 covers only surfaces that exist. The six endpoints whose rooms are empty stubs are a named follow-on — paginated when their rooms are built. CR-2 stays a pagination CR, not a room-building CR.

The limit=200 → paginated swap is intended. Three surfaces (engagements, Memory/assertions, Rendering/renders) currently fetch wholesale limit=200. CR-2 swaps each to cursor pagination with a per-surface default (~15–40). This is a visible behaviour change — a user who today sees up to 200 rows at once will see one page plus a load-more control. Named explicitly because it is the one place CR-2 changes what an existing surface shows. It is the intended change — the whole point of the work — and satisfies the seed's "only show what is available" + total_count-surfaced shape.


3. The reusable load-more control (the spine)

One control, built once, applied to every paged surface. Generalised from the precedent that already exists: CenterPane's "load earlier" control (ConversationPane.tsx:179).

Behaviour (settled pattern, from the handoff): - Explicit, not infinite scroll. A control the Operator clicks/taps to load the next page. (Operator-authority posture: the surface signals "more exists"; the Operator chooses to load it.) - has_more-gated. The control appears only when has_more is true. When has_more is false, the control does not render — no disabled state, no greyed-out control (seed: "only show what is available"). - total_count always visible. The surface shows "showing N of TOTAL" (or equivalent) whenever a list is paged, so no truncation is silent (handoff rule 2). - Parameterised by item-key. Step-0 A found the response item-array key varies per endpoint — most use the endpoint's natural key, compositions uses items, conversation-history uses turns. The control takes the item-key as a prop rather than assuming a fixed field, so one control serves all surfaces. (Also ignore assertions' vestigial limit response field — Step-0 A; rely on has_more/next_cursor.)

Paged-list state (the reusable hook/store): - Holds: accumulated items, next_cursor, has_more, total_count, loading flag. - First fetch on mount: limit=<surface default>, no cursor. - Load-more: fetch limit=<default>&cursor=<next_cursor>, append to accumulated items, update next_cursor/has_more. - Direction note: most surfaces prepend nothing (append newest-first or oldest-first per the surface); CenterPane is the exception — it loads earlier turns and prepends (existing behaviour, preserved). The reusable state supports both append and prepend modes.

Settled (D-3): extract a shared control; CenterPane adopts it at Step 6. Build the reusable control to the general list shape; CenterPane migrates onto it, keeping its dedup guard as a CenterPane-level concern. The precedent becomes the reusable thing rather than a parallel implementation. [CC reads the existing CenterPane control's shape (ConversationPane.tsx:179) to extract the general control cleanly and identify what stays CenterPane-specific — the prepend bias is a mode the shared control supports; the dedup guard stays at CenterPane.]


4. Step plan — built from Step-0's live surface inventory

Engine setting plumbing first (it is the only engine touch and unblocks the override), then the control, then each live surface, then home, then the global override, then CenterPane migration last (it is both a surface and the precedent).

Step 1 — engine: lists.page_size setting plumbing

Repo loomworks-engine (the only engine touch in CR-2)
What Add lists.page_size as a bounded-numeric setting on the confirmed three-place pattern (Step-0 C).
The three places (1) KNOWN_SETTINGS entry in preferences/person_settings.pySettingSpec(key='lists.page_size', display_name=…, default=None, validator=…, bounds=(10,100)); nullable default (unset → per-surface defaults govern). (2) _LABEL_TO_KEY synonym set in orchestration/tune_setting.py (e.g. "list size", "page size", "rows per page"). (3) the frontend applySettingChange branch (useConversation.ts:71) — Step 4 of this CR.
Bounds [10, 100] (handoff lean; confirm).
Mechanics Inherits tune_setting (bounded, named-back on change, FORAY setting_change audit) + direct PUT /me/settings/{setting_key} (me_settings.py:96, confirmed live). No new setting mechanics — lists.page_size is an ordinary entry in the existing system.
Test setting accepts in-bounds, rejects out-of-bounds, named-back on change, FORAY audit emitted, nullable unset state honoured. Engine suite green.

Step 2 — the reusable load-more control + paged-list state

Repo loomworks (OL)
What Build the control + state per §3. No surface wired yet — this step is the reusable primitive + its tests in isolation.
Resolves page size The control's effective page size = lists.page_size if the Operator has set it (non-null), else the surface's per-surface default (passed as a prop). The global override, when set, uniformises; unset, per-surface governs.
Test mount fetches first page; load-more appends and advances cursor; control hidden when has_more false; total_count rendered; item-key prop selects the right array; append + prepend modes both work.

Step 3 — apply to the wholesale endpoint surfaces (renders + Memory built; engagements deferred — §5a)

Surfaces engagements (home "Your engagements" list), Memory room (assertions), Rendering room (renders) — each currently fetches wholesale limit=200 (Step-0 D)
What Replace the limit=200 wholesale fetch with the Step-2 paged-list state + control. Per-surface defaults (Step 5). The limit=200→paginated swap is the intended visible change (§2).
Per-endpoint item-key engagements, assertions, renders each use their natural item key — [CC confirms each from the live response]. Renders direction (D-4 — RESOLVED at build, land-on-oldest): the v0.2 lean was "land newest, page older." Build found the engine renders endpoint sorts created_at ASC and pages forward (oldest→newer) — a forward-only keyset cannot land on the newest page, so the fallback the CR reserved applies: page 1 = oldest, load-more = newer. This matches what RenderingRoom shows today (no reversal) — so it is not even a display change. The mechanics governed; the lean was set aside.
Test each surface pages; first page ≤ default; load-more works; total_count shown; empty and single-page states render without a stray control.

Step 4 — home lenses re-wire (frontend-only, no engine)

Surfaces the two across-engagements home lenses — consume /me/dashboard/needs_you + /recent (lib/api/dashboard.ts:72,119) + /engagements
Step-0 B finding These dashboard sections are already keyset-conformant (Group A on the engine); the frontend parses next_cursor then throws it away. So home is a pure re-wire — no engine work, no new fetch shape.
What Stop discarding next_cursor; wire the Step-2 control to the existing dashboard fetches. Also wire the applySettingChange branch for lists.page_size here (useConversation.ts:71) — the third of the three settings places.
Test home lenses page; the discarded-cursor regression is closed; lists.page_size change applies live to the home lists.

Step 5 — per-surface page-size defaults

What Set each live surface's default page size, surface-purpose-tuned (handoff decision (b)).
Defaults (lean, set with surfaces in view) reading surfaces (Memory room, Rendering room) ~15; glance surfaces (home engagement list) ~40; urgency lists (needs_you) ~15. [Confirm the actual numbers at this step with the live surfaces — not load-bearing for the structure.]
Override interaction these are the defaults lists.page_size overrides when the Operator sets it; unset, these govern.
Test each surface opens at its default; setting the global override uniformises all; clearing it restores per-surface.

Step 6 — CenterPane migration: beforecursor (the breaking site + the precedent)

Surface CenterPane / conversation-history (ConversationPane.tsx:179, the existing "load earlier" control)
Step-0 A finding conversation-history's before param is gone — the live param is cursor (CR-1 Step 10 replaced it). CenterPane currently calls with before — this is the one breaking frontend site.
The call sites — CORRECTED at build The CR said two (useConversation.ts + ChatView.tsx). Build found one: useConversation.ts (Axis 11) was the only before sender; ChatView.tsx is a one-shot initial fetch that never sent before (it compiles unchanged against the widened response). The CR's §5a/Step-0 D already said "home, no before" — the "two call sites" framing was imprecise, corrected here per live-source-over-restatement. ConversationPane.tsx:179 confirmed as the control.
What Migrate the call from before=<oldest raw created_at> to cursor=<next_cursor>. CenterPane's load-earlier (prepend) behaviour is preserved. If §3's lean was taken — extract the shared control — CenterPane adopts the Step-2 control here, so the precedent becomes the reusable thing. Also: conversation-history now returns total_count (CR-1 added it) — surface it.
R2 note CR-1's composite (created_at, id) cursor closes the R2 boundary-tie skip the Axis 11 phase left open — this migration is what realises that fix on the surface. The boundary-tie is now handled engine-side; the frontend just passes the opaque cursor.
Test load-earlier still works (no skip, no duplicate — the R2 case now engine-handled); total_count shown; both call sites pass cursor; the dedup guard from Axis 11 still holds as a belt-and-braces.

5a. Engagements home list — deferred (structural blocker, found at build)

Step 3's third surface (engagements / home "Your engagements") cannot be paginated as scoped and is deferred to a named follow-on. Recorded in full per corrections-preserved discipline.

The blocker. The home "Your engagements" list is not a direct list of a paged endpoint — it is a client-side-derived zone. useHomeData fetches the entire /engagements response and classify() (useHomeData.ts:82–105) splits every row into visibility zones from per-row signals: Personal (operator_sequence_number === 0), Shared/commons (title === BRAND.productName), Administrative/E0001 (hidden), and Yours (everything else). The classification requires the whole set. And because /engagements sorts created_at DESC (CR-1 Step 1), the Personal and commons engagements are the oldest — they land on the last pages. Paginating the raw fetch would mis-classify the zones (Personal and Shared would vanish from the home view) until the user paged all the way to the end. There is no flat, un-zoned engagements list anywhere (fetchProjectSummaries has exactly one consumer — useHomeData; Home.tsx renders zones.*, not a flat list).

Why it cannot be cleanly paginated now. The engine has no visibility field on /engagements — the zones are a documented client heuristic awaiting a real visibility='automatic' engine projection (useHomeData.ts:12–24), a gap Step-0/CR-1 already named. With no zone-scoped endpoint to page, pagination of the raw list breaks the home's zone model. This is a missing substrate, not a wiring problem.

Options considered and the call. - A — defer (chosen). Record the home engagements list as a follow-on, dependency named: clean pagination needs the engine visibility projection first, then zone-scoped paginated endpoints. Honest; no broken zones. Practical stakes nil — 7 engagements today, nowhere near a problem. - B — client-side DOM cap (rejected). Cap rendered rows at ~40 + a "show more" over the still-full fetch. Bounds the DOM, not the engine fetch — fails CR-2's actual goal (stop fetching everything) while looking done, which is worse than not doing it. Needs a client-only pager the cursor spine doesn't fit; bespoke throwaway. - C — engine visibility projection now (rejected for CR-2). The correct end-state, but a whole engine CR (new projection + zone-scoped endpoints) — out of CR-2's frontend scope; deserves its own scoping, not silent absorption.

The deferral, with its unblock condition. The home engagements-list pagination is a tracked downstream of the engine visibility='automatic' projection. When that projection is built, the home-list pagination is its waiting consumer. Not a forgotten loose end — a recorded follow-on with a named dependency. Step 3 is therefore complete at the two room surfaces (renders, Memory); engagements correctly discovered a substrate dependency the room surfaces did not have.

5. Out of scope (named follow-ons)


6. The four decisions — settled

All four resolved with the Operator at drafting. Rationale recorded per corrections-preserved discipline.

# Decision Settled
D-1 lists.page_size bounds [10, 100]. Floor 10 keeps a page meaningful (fewer fires the control constantly); ceiling 100 stops the override recreating the wholesale limit=200 problem CR-2 fixes.
D-2 Per-surface defaults reading ~15 (Memory, Rendering), glance ~40 (home engagement list), urgency ~15 (needs_you) — starting numbers, set at Step 5 with surfaces in view, tunable in one place after. Not structural.
D-3 Shared control: extract vs generalise CenterPane in place Extract. Build the clean reusable control to the list shape (append/prepend mode, item-key prop, has_more-gate, total_count); CenterPane adopts it at Step 6, keeping its dedup guard as a CenterPane-level concern layered on top. See rationale below.
D-4 Renders load-more direction (sort is ASC/oldest-first per CR-1) Lean was land-newest/page-older; RESOLVED at build to land-on-oldest. The renders keyset is forward-only ASC (oldest→newer); it cannot land on the newest page. So page 1 = oldest, load-more = newer — the fallback the CR named. Matches current RenderingRoom behaviour (no reversal), so no display change. Mechanics governed the lean.

D-3 rationale (the one structural, expensive-to-reverse call). CenterPane's existing control is shaped for a conversation stream: it loads earlier turns, prepends, and carries a dedup guard. The other five surfaces append, no dedup. Generalising CenterPane's control in place would bend a conversation-stream control to cover list surfaces, leaving list pagination dependent on conversation-specific machinery — a seam that drifts (a later change to conversation dedup silently changes how the Memory room pages). Extracting inverts it: the shared control is genuinely general (the list shape), CenterPane's conversation behaviour stays at CenterPane. Same discipline as CR-1's codec lift — generalise the shared thing properly rather than propagating a special case. CenterPane's dedup guard is now redundant (CR-1's composite cursor handles the R2 skip it guarded against) but kept as harmless belt-and-braces.


7. Done criteria

CR-2 done when: lists.page_size lands on the three-place pattern (engine suite green); the reusable control + paged-list state built and tested in isolation; the three wholesale surfaces (engagements, Memory, Rendering) swapped to cursor pagination; the two home lenses re-wired to stop discarding next_cursor; per-surface defaults set; CenterPane migrated beforecursor with load-earlier preserved and total_count surfaced; both conversation-history call sites pass cursor; OL suite green at each step; verified against playground_dev. The six roomless endpoints remain a named follow-on. Push on separate authorisation.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks list-loading — CR-2: frontend load-more + page size — v0.5 — 2026-06-27 Frontend CR against merged CR-1 (eeae9c2/0096). BUILT: engine lists.page_size + reusable spine + renders + Memory + home lenses + defaults/override + CenterPane (R2 closed end-to-end, count suppressed on conversation). Engagements + six roomless endpoints deferred to named engine dependencies. OL suite 415. Branches built, not pushed. Supersedes v0.4–v0.1.