Document: loomworks-list-loading-cr-2-frontend-load-more-v0_3
Version: v0.3
Status: working draft — Steps 1–2 built; Steps 3–6 in progress (split per surface); D-4 resolved empirically at build
Date: 2026-06-27
Supersedes: loomworks-list-loading-cr-2-frontend-load-more-v0_2 (2026-06-27), …-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.
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.
| 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–6 | In progress, split per surface (blast-radius discipline — see below). |
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:
has_more-gated, total_count surfaced, parameterised by item-key.limit=200 → cursor; CenterPane migrates before → cursor).next_cursor.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):
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).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):
next_cursor, has_more, total_count, loading flag.limit=<surface default>, no cursor.limit=<default>&cursor=<next_cursor>, append to accumulated items, update next_cursor/has_more.
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 — 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. |
| | |
|---|---|
| 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) | 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.
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.3 — 2026-06-27 Frontend CR against merged CR-1 (eeae9c2/0096). Steps 1–2 built (engine setting + reusable spine); Steps 3–6 split per surface for blast-radius. D-4 resolved at build to land-on-oldest (forward-only ASC keyset). Supersedes v0.2, v0.1.