Document: loomworks-list-loading-cr-2-frontend-load-more-v0_2
Version: v0.2
Status: working draft — four open decisions settled (§6); awaiting Operator approval to build
Date: 2026-06-27
Supersedes: loomworks-list-loading-cr-2-frontend-load-more-v0_1 (2026-06-27)
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.
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.
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 before → cursor).
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).
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.
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.]
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).
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.py — SettingSpec(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. |
| 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. |
| 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 settled): land newest, page older — renders sorts ASC/oldest-first per CR-1 Step 9, but the surface presents newest-first like every room (a reader wants the most recent render first). The surface fetches the tail of the ASC sequence and pages backward. [CC confirms the keyset supports paging the tail of an ASC sort cleanly; if awkward, fallback is land-on-oldest — surface it, don't fight the mechanics.] |
| Test | each surface pages; first page ≤ default; load-more works; total_count shown; empty and single-page states render without a stray control. |
| 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. |
| 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. |
before → cursor (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 two call sites | useConversation.ts (Axis 11, passes before) and ChatView.tsx (home, no before) — both updated to the cursor param. [CC confirms both call sites against the live OL — the handoff named these two; Step-0 confirms ConversationPane.tsx:179 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. |
items (Step-0 A outlier) — the control's item-key prop already handles it.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) | Land newest, page older. The ASC sort is the right storage/sequence order; the surface presents newest-first like every other room (a reader wants "what did I render last"). Surface-presentation layered on CR-1's storage order. Confirm cursor mechanics support paging the tail of an ASC sequence at Step 3; fallback land-on-oldest, surfaced not fought. |
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.
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 before→cursor 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.2 — 2026-06-27
Frontend CR against the merged CR-1 contract (eeae9c2/0096). Reusable load-more (extracted shared control) on 4 live surfaces + 2 home lenses; six roomless endpoints deferred. One engine touch: the lists.page_size setting. Four decisions settled. Supersedes v0.1.