Version. 0.2
Date. 2026-05-31
Author. Claude.ai (this conversation)
Changes from v0.1. Folds in two pre-flight findings the Operator accepted. R1 (correctness): loadEarlier now guards against an engagement switch in flight (§B.2) — the prepend is gated on project_id being unchanged since the call was issued, mirroring the mount effect's cancelled-closure guard. R2: §B.3 is unchanged, and the cursor-skip residual is now also surfaced as an explicit known-limitation line in the acceptance criteria (§F). N1: the load-earlier control stays at the older-turn edge (bottom in the default newest-first order), consistent with CenterPane's deliberate non-chat-app posture; final placement is marked a smoke-test confirmation point. Prior v0.1 preserved unchanged.
Working machine. The Mac Mini, with Claude Code as the executor.
Repository. DUNIN7/loomworks (Operator Layer frontend) — frontend only. No substrate change.
Substrate baseline. loomworks-engine main at tag phase-66-spend-visibility-and-pause (commit 1109fcf). Read-only here — the endpoint already exposes everything this needs (before cursor + has_more); confirmed at Step 0a.
Frontend baseline. loomworks main at tag phase-66-spend-visibility-and-pause (commit b77b987).
Source. Scoping note v0.2 (…/loomworks-phase-axis-11-conversation-pagination-scoping-note-v0_2, record 9429b2a) and CR drafting handoff v0.1 (record 20bfdc1). Step 0 of the handoff was executed and accepted: 0a (fetch-code read), 0b (live browser observation of the scroll model), 0c (surface target = CenterPane-only).
Grounded in. A direct reading of current code on main (b77b987 / 1109fcf) and a live-browser measurement of CenterPane with 54 real turns (E0005, 2026-05-31). File:line citations throughout are from those commits.
Today, opening an engagement loads only the most recent 50 conversation turns and there is no way to see older ones — the substrate already supports paging back, but the surface never asks. This change-request adds a "load earlier conversation" control to the in-engagement surface (CenterPane). When older turns exist, the control appears; clicking it loads the next page of older turns and folds them into the conversation, keeping what you were reading visually still.
It is frontend only. The engine endpoint already exposes the before cursor and the has_more flag (confirmed at Step 0a); the client type already carries has_more (src/lib/types.ts:90) — the surface simply stops throwing it away and starts using it. Nothing touches Memory — pagination is a pure read of conversation history that already exists; it writes nothing, derives nothing, touches no provenance.
A live-browser check at Step 0b established the one fact the build hinges on: the page grows; the inner conversation container does not scroll. On real CenterPane with 54 turns the document was 5128px tall in a 900px viewport, the window was the scroller, and the conversation <div> — despite overflow-y:auto — was exactly as tall as its content and refused to scroll. So scroll-position preservation is keyed to the window, not to any container scrollTop.
What lands.
has_more: it appears only when older turns exist, and disappears at the start of history. (The settled §6 Q2 decision: explicit control, not infinite scroll.)res.has_more in its history-fetch .then (CenterPane.tsx:357–380) and tracks it in state.…&before=<oldest raw turn's created_at> — and prepends those turns into CenterPane's turns state.document height + window.scrollY before; restore window.scrollTo(0, scrollY + heightDelta) after) so the turns you were reading stay put when older ones load above them.turn_id guard on prepend, so a turn can never render twice.[turns]-keyed scroll effect (CenterPane.tsx:429–437), which assumes a container scroller that does not exist, is neutralized for the load-earlier path so it cannot fight the window-scroll restore.What you (the Operator) see after this ships.
Open an engagement with a long history (E0005 Loomworks has 96 of your turns). The most recent 50 load as today. Where the older turns live in the flow (below, in the default newest-first order), a "Load earlier conversation" control sits. Click it — the next page of older turns folds in, the turns you were looking at don't jump, and the control stays until you reach the very first turn, then it goes away.
What this change-request does not do.
before and has_more already exist (conversation_history.py:63, 116); the response shape already carries has_more (schemas.py:475). Frontend only.Estimated effort. One checkpoint. A single frontend component plus its first test file. No migration, no substrate, no new endpoint.
Step 0 of the handoff (0a/0b/0c) is already executed and accepted; this block is the re-confirm before building, to catch any drift since b77b987.
cd /Users/dunin7/loomworks && git status && git log --oneline -1
cd /Users/dunin7/loomworks-engine && git status && git log --oneline -1
Both working trees clean; frontend at b77b987, engine at 1109fcf (or later). If either has moved, re-read the two fetch sites and the endpoint before proceeding.
sed -n '56,156p' /Users/dunin7/loomworks-engine/src/loomworks/orchestration/routers/conversation_history.py
sed -n '475,483p' /Users/dunin7/loomworks-engine/src/loomworks/orchestration/schemas.py
Confirm still true: before: Optional[datetime] query param applied as created_at < before (line ~104); has_more = len(rows) > limit over a limit+1 fetch (lines ~112–116); response {turns: ConversationTurn[], has_more: bool} (schemas.py:475), turns oldest-first within the window.
sed -n '349,409p' "/Users/dunin7/loomworks/src/app/operator/engagement/[engagement_address]/CenterPane.tsx"
sed -n '90,93p' /Users/dunin7/loomworks/src/lib/types.ts
sed -n '628,644p' "/Users/dunin7/loomworks/src/app/operator/engagement/[engagement_address]/CenterPane.tsx"
Confirm still true: the mount fetch is …?project_id=…&limit=50, maps res.turns, and discards res.has_more (CenterPane.tsx:357–380); the client ConversationHistoryResponse carries has_more (types.ts:90–93); turns state is ascending (oldest at index 0) and orderedTurns reverses only for newest_first (CenterPane.tsx:628–632).
If 0.2/0.3 match, proceed to Section A. If anything has drifted (the endpoint, the response shape, the order semantics, the filter), halt and report — the build below is written against these exact shapes.
has_more instead of discarding it
In the mount fetch (CenterPane.tsx:357 .then), after setTurns(...), also set new state hasMore from res.has_more. Add:
const [hasMore, setHasMore] = useState(false);.then: setHasMore(res.has_more);setTurns([]) (mount start line 353, the 404 branch line 395): also setHasMore(false).created_at
The client filters turns (_filterNonConversationTurns, CenterPane.tsx:257/359), so the oldest visible turn may be newer than the oldest raw turn of the loaded window. The cursor must be the oldest raw turn's created_at, or filtered turns would be re-fetched and the cursor could stall.
const oldestCursorRef = useRef<string | null>(null);.then, before filtering: oldestCursorRef.current = res.turns[0]?.created_at ?? null; (the response is oldest-first, so res.turns[0] is the raw oldest of the window).oldestCursorRef.current = null;hasMore
Older turns live at the edge of the flow that depends on messageOrder (CenterPane.tsx:628–632):
newest_first (default, observed live): older turns render at the bottom → the control renders after the grouped list.oldest_first: older turns render at the top → the control renders before the grouped list.
Render, only when hasMore is true and a load is not in flight, a button: "Load earlier conversation" (data-testid="load-earlier"), with a loading state ("Loading earlier…", disabled) while a fetch is in flight and an inline error slot for a failed load. When hasMore is false the control is absent (end of history — "only show what is available").
A loadEarlier callback, guarded against concurrent runs (const [loadingEarlier, setLoadingEarlier] = useState(false); plus an in-flight ref):
const before = oldestCursorRef.current;
if (!before || loadingEarlier || !hasMore) return;
const issuedFor = engagement.project_id; // R1 — capture the project this load is for
setLoadingEarlier(true);
api<ConversationHistoryResponse>(
`/operator/conversation-history?project_id=${encodeURIComponent(issuedFor)}`
+ `&limit=50&before=${encodeURIComponent(before)}`,
)
before is the raw created_at string the client received from the engine; URL-encoded, it round-trips through the same ISO-8601 (de)serialization FastAPI uses to parse Optional[datetime] (signature-grounding, pre-flight shape 2 — verified against the live endpoint: created_at is emitted as 2026-05-20T19:46:36.605314Z and parses back through before= cleanly). Same project-scoped path, same limit=50, plus the cursor. issuedFor is captured at call time for the R1 staleness guard in §B.2.
turn_id guard
On success — first, the R1 project-switch guard. The mount fetch resets turns and refetches on [engagement.project_id] change (CenterPane.tsx:350, 406) using a cancelled closure. loadEarlier is a separate callback with no such protection in v0.1; if the Operator switches engagements while a load-earlier is in flight, the response could resolve after the mount reset and prepend turns from the previous engagement. Guard it the same way the mount effect does — bail before touching any state if the engagement changed since the call was issued:
if (issuedFor !== engagement.project_id) return; // R1 — stale load for a since-switched engagement; drop it
Place this as the first line of the .then, before the cursor update, the setHasMore, and the prepend. (The finally still clears loadingEarlier — see below — but a cancelled-style ref keyed to project may be used instead if the executor prefers symmetry with the mount effect; either realises the same guarantee.) Then, on a non-stale success:
oldestCursorRef.current = res.turns[0]?.created_at ?? oldestCursorRef.current;setHasMore(res.has_more);_filterNonConversationTurns and map to DisplayTurn exactly as the mount fetch does (CenterPane.tsx:359–379) — same shape, no divergence.turns array, skipping any turn_id already present:
setTurns((prev) => {
const seen = new Set(prev.map((t) => t.turn_id));
const fresh = mappedOlder.filter((t) => !seen.has(t.turn_id));
return [...fresh, ...prev]; // older turns are smaller created_at → front
});
The Set guard makes the prepend idempotent — a double-click, a React StrictMode double-invoke, or a retry can never double-render a turn.
On error: leave turns and the cursor unchanged, surface the inline error in the control's slot; the Operator can retry. finally: setLoadingEarlier(false).
created_at-cursor / id-tiebreak edge) — accepted at v0.1
The engine cursor is created_at < before (strict), while its ordering tiebreaks on id DESC within an equal created_at (conversation_history.py:104, 108–110). Two turns sharing an exact created_at across a page boundary therefore have a real edge:
created_at but sorts after it by id, the strict < cursor excludes it and it is never loaded.
A complete fix is a composite (created_at, id) cursor, which is an engine change and therefore out of this frontend-only scope. Accepted at v0.1 because conversation turns are written sequentially with distinct sub-second created_at values in practice (the boundary collision is rare), and the dedup guard already makes a future move to an inclusive (<= + id) cursor safe. Flagged as the one residual correctness gap (see pre-flight findings). If it ever bites, the fix is engine-side and small.
The window is the scroller (0b: page grows, container does not scroll). When older turns insert above the current viewport — the oldest_first case, control at top — the view would jump down by the height of the inserted content unless corrected. Correct it on the window:
setTurns, capture prevHeight = document.documentElement.scrollHeight and prevY = window.scrollY.useLayoutEffect that runs after the prepend commits (gated by a "restore pending" ref set in loadEarlier), compute document.documentElement.scrollHeight - prevHeight and call window.scrollTo(0, prevY + delta). Running in useLayoutEffect (not useEffect) applies the correction before paint, so there is no visible flash.
When older turns insert below the viewport — the newest_first default, control at bottom — the turns being read do not move, so no window correction is applied (applying the delta there would wrongly push the view down toward the new content). Gate the restore to the at-top insertion case (messageOrder === "oldest_first"). State this gating explicitly in code comments.
[turns]-keyed scroll effect for the load-earlier path
CenterPane.tsx:429–437 runs on every turns change and sets conversationRef.current.scrollTop. Step 0b confirmed this is inert — the container does not scroll, so scrollTop stays 0 (measured: forced scrollTop = 99999 → read back 0). It cannot actively move the window today, but it must not fight the C.1 restore, and if the layout is ever height-bounded it would. Gate it so it does not run on a load-earlier-driven turns change:
prependInFlightRef.current = true) in loadEarlier before the prepend; in the effect at 429–437, early-return when it is set, and clear it in C.1's layout effect after the restore.The effect's existing behavior for new turns (send / receive, which append to the end) is unchanged.
CenterPane has no test file today (confirmed: no CenterPane under tests/). This CR adds the first one — tests/components/engagement/CenterPane.pagination.test.tsx — following the repo's established pattern (tests/components/responsive.test.tsx, tests/components/chat/ChatView.test.tsx): jsdom + React Testing Library, providers mocked (AuthProvider, SSEProvider, next/navigation), and vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ turns, has_more })).
Tests:
has_more gates the control. Mount with has_more: false → no load-earlier control. Mount with has_more: true → control present.has_more: true), click load-earlier, second fetch resolves an older page → the older turns render in order, and the fetch URL carries &before=<page-1 oldest raw created_at> (assert the exact query string).has_more: false → the control disappears after that load.turn_id. The older page overlaps the loaded set by one turn_id → that turn renders exactly once (assert a single element for it).before on the next fetch is that raw turn's created_at, not the oldest visible one.scrollHeight/getBoundingClientRect heights), so this test asserts the mechanism, not pixels: stub document.documentElement.scrollHeight to differ before/after the prepend and window.scrollY, spy on window.scrollTo, and assert it is called with prevY + (after − before) in the oldest_first case — and not called in the newest_first case. (State this jsdom constraint in the test file header.)conversationRef.scrollTop reset path (e.g. spy/guard), so C.2's gate holds.has_more: true); begin a load-earlier whose fetch is left pending; switch to engagement B (re-mount / prop change); then resolve the pending A response → assert engagement B's turns are not prepended with A's older page (the stale response is dropped by the issuedFor !== engagement.project_id guard).All existing vitest tests continue to pass.
Checkpoint (frontend).
cd /Users/dunin7/loomworks
npm test
npm run build
New CenterPane pagination tests plus the full existing suite pass; build succeeds.
Smoke test (real surface, real data).
/auth, person 6d867b23…) and open /operator/engagement/E0005 (Loomworks — 96 of his turns).has_more holds.has_more)."show oldest first" to the Companion), reload, and confirm the control sits at the top and the view stays anchored when older turns load above.Halt for Operator visual confirmation.
load-earlier control appears, gated on has_more; it is absent at the start of history.…&before=<oldest raw created_at>&limit=50 and prepends the older page into turns.turn_id).oldest_first); no spurious scroll when they load below (newest_first).[turns]-keyed scroll effect does not fire on the load-earlier path.issuedFor !== engagement.project_id guard).
Known limitation (R2) — accepted at this phase, follow-on noted. The created_at-only, strict-< cursor can skip a turn that shares the exact boundary created_at and sorts later by id (engine ordering tiebreaks on id DESC; conversation_history.py:104, 108–110). The dedup-by-turn_id guard prevents duplicates but not this skip. The complete fix is an engine-side composite (created_at, id) cursor, which is out of this frontend-only phase's scope and is recorded as a follow-on. Accepted here because turns are written with distinct sub-second created_at in practice (verified on live data: 96 turns paged as 50 + 46 with zero overlap and none lost).
Placement confirmation (N1). The load-earlier control sits at the older-turn edge — the bottom of the flow in the default newest_first order — consistent with CenterPane's deliberate non-chat-app posture. Final placement is a smoke-test confirmation point (§E step 2/6): it can be revisited once the surface is seen with real data, without reopening the rest of the CR.
before/has_more already exist; the composite-cursor fix for §B.3 is explicitly deferred engine-side.
The five-shape review ran at v0.1: 0 blockers, 2 recommended (R1, R2), 3 non-blocking (N1–N3). v0.2 disposition: R1 folded in as the project-switch staleness guard (§B.2) with a test (§D.8) and an acceptance criterion (§F.6) — a correctness fix, not optional. R2 kept as §B.3 and additionally surfaced as the known-limitation line in §F. N1 resolved — control stays at the older-turn edge, with placement marked a smoke-test confirmation point. N2 (jsdom has no layout engine → §D.6 tests the mechanism, not pixels) and N3 (filter/has_more UX edge) stand as noted, no change required. The cursor round-trip and termination were additionally verified against the live endpoint at pre-flight. No blockers.
Approved for execution.
- Marvin Percival
- [timestamp]
Once approval is recorded, Claude Code executes the single frontend checkpoint, halting at the smoke test for visual confirmation. Not approved yet — pre-flight only.
DUNIN7 — Done In Seven LLC — Miami, Florida
Loomworks — Axis 11 Conversation Pagination change-request — v0.2 — 2026-05-31
Source: scoping note v0.2 (record 9429b2a) + CR drafting handoff v0.1 (record 20bfdc1); Step 0 executed and accepted (0a fetch-read, 0b live browser observation, 0c CenterPane-only); five-shape pre-flight folded into v0.2 (R1 + R2). Prior v0.1 preserved.
Builds on phase-66-spend-visibility-and-pause (frontend b77b987, engine 1109fcf).
Requires Operator approval before Claude Code begins execution.