feat(6839): the decision log and the knowledge feed load as you scroll, through one ScrollSentinel
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s

ScrollSentinel sits at the foot of a server-paged list and asks for the
next page a screen before the reader reaches it. It asks once per cursor,
forgets the cursor when its element goes, and shows Try again on a failed
page instead of retrying in a loop.

FamilyView's decision log loses its "Older decisions" button. A failed
page shows in place rather than as a toast, and rows already shown are
skipped when a new decision shifts the offset.

KnowledgeView drops its inline IntersectionObserver for the component.
The cursor is the ids consumed, not the items rendered, so a batch that
returns fewer records than it asked for still moves the feed on. A failed
batch puts its ids back and offers Try again, where it used to fail
silently and stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:14:41 -05:00
co-authored by Claude Opus 5.5
parent 846c87629f
commit 373f5bd454
3 changed files with 167 additions and 74 deletions
@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* The end of a server-paged list: asks for the next page as the reader nears
* it, in place of a "Load more" button. The parent keeps the paging — what the
* next page is keyed on, whether one is in flight, whether the last one
* failed — and this owns the observing.
*
* <ScrollSentinel :cursor="more ? String(rows.length) : ''"
* :loading="loadingMore" :failed="moreFailed" @more="loadMore" />
*
* An empty cursor means the list is complete, and nothing renders.
*/
import { onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{ cursor: string; loading: boolean; failed?: boolean }>();
const emit = defineEmits<{ more: [] }>();
const sentinel = ref<HTMLElement | null>(null);
const visible = ref(false);
// One request per page. The observer reports every crossing, and a short last
// page leaves the sentinel on screen for good, so the cursor last asked for is
// remembered: a page is asked for once, and a failed one is not retried on
// every scroll, because a failure leaves the cursor where it was. Only
// Try again asks for it again.
let asked = "";
function pump() {
if (!visible.value || props.loading || props.failed || !props.cursor) return;
if (props.cursor === asked) return;
asked = props.cursor;
emit("more");
}
function retry() {
asked = props.cursor;
emit("more");
}
let observer: IntersectionObserver | undefined;
function observe(el: HTMLElement | null) {
observer?.disconnect();
observer = undefined;
if (!el) {
// Visibility and the cursor asked for go with the element. A list that
// starts over must not inherit "the end is on screen" from the one it
// replaced, and an offset cursor repeats from list to list, so "50" asked
// of the old list must not stop the new one asking for its own "50".
visible.value = false;
asked = "";
return;
}
observer = new IntersectionObserver(
(entries) => {
visible.value = entries.some((e) => e.isIntersecting);
pump();
},
// Extends the watched area a screen below the viewport, so the next page is
// asked for before the reader reaches the end rather than when they do.
{ rootMargin: "0px 0px 100% 0px" },
);
observer.observe(el);
}
// flush: "post": the template ref is only set once the DOM is patched.
watch(sentinel, observe, { flush: "post" });
// A page that lands without pushing the end off screen gives the observer
// nothing new to report, so carry on from the props, or the list stops early.
watch([() => props.cursor, () => props.loading, () => props.failed], pump);
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<div v-if="cursor" class="sentinel">
<!-- The one case with a button: an observer re-firing on a failure would be
a request loop. -->
<p v-if="failed" class="sentinel-note sentinel-failed" role="alert">
Couldn’t load more.
<button type="button" class="btn-secondary btn-sm" @click="retry">Try again</button>
</p>
<template v-else>
<div ref="sentinel" class="sentinel-mark" aria-hidden="true"></div>
<p v-if="loading" class="sentinel-note" role="status">Loading more…</p>
</template>
</div>
</template>
<style scoped>
.sentinel { grid-column: 1 / -1; padding: 0.75rem 0; text-align: center; }
.sentinel-mark { height: 1px; }
.sentinel-note { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.sentinel-failed { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
</style>