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>
+21 -7
View File
@@ -19,6 +19,7 @@ import {
type FamilyIdea, type IdeaSnapshot, type IdeaStatus, type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
} from "@/api/family"; } from "@/api/family";
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue"; import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
import ScrollSentinel from "@/components/ScrollSentinel.vue";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { fmtStamp } from "@/utils/dateFormat"; import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref"; import { recordHref } from "@/utils/recordHref";
@@ -36,6 +37,8 @@ const loading = ref(true);
const failed = ref(false); const failed = ref(false);
const moreDecisions = ref(false); const moreDecisions = ref(false);
const loadingMore = ref(false); const loadingMore = ref(false);
const moreFailed = ref(false);
const decisionOffset = ref(0);
// One pending reversal at a time: which row, which act, and its reason. // One pending reversal at a time: which row, which act, and its reason.
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null); const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
@@ -63,7 +66,9 @@ async function load() {
criteria.value = ideaData.criteria; criteria.value = ideaData.criteria;
conflictOrder.value = ideaData.conflict_order ?? []; conflictOrder.value = ideaData.conflict_order ?? [];
decisions.value = log; decisions.value = log;
decisionOffset.value = log.length;
moreDecisions.value = log.length === PAGE; moreDecisions.value = log.length === PAGE;
moreFailed.value = false;
} catch { } catch {
// Said out loud: a log that failed to load must not read as "nothing // Said out loud: a log that failed to load must not read as "nothing
// has been decided". // has been decided".
@@ -73,14 +78,20 @@ async function load() {
} }
} }
// Older decisions load as the log is scrolled. The log is offset-paged, so a
// decision recorded since the first page shifts the rest down by one; a row
// already shown is skipped, and the offset still counts what the server sent.
async function loadMore() { async function loadMore() {
loadingMore.value = true; loadingMore.value = true;
moreFailed.value = false;
try { try {
const next = await listFamilyDecisions(PAGE, decisions.value.length); const next = await listFamilyDecisions(PAGE, decisionOffset.value);
decisions.value.push(...next); const shown = new Set(decisions.value.map((d) => d.id));
decisions.value.push(...next.filter((d) => !shown.has(d.id)));
decisionOffset.value += next.length;
moreDecisions.value = next.length === PAGE; moreDecisions.value = next.length === PAGE;
} catch (e: unknown) { } catch {
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error"); moreFailed.value = true;
} finally { } finally {
loadingMore.value = false; loadingMore.value = false;
} }
@@ -343,9 +354,12 @@ onMounted(load);
</form> </form>
</li> </li>
</ul> </ul>
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore"> <ScrollSentinel
{{ loadingMore ? "Loading…" : "Older decisions" }} :cursor="moreDecisions ? String(decisionOffset) : ''"
</button> :loading="loadingMore"
:failed="moreFailed"
@more="loadMore"
/>
</section> </section>
</template> </template>
</main> </main>
+50 -67
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue"; import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note"; import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
@@ -10,6 +10,7 @@ import UsageBadge from "@/components/UsageBadge.vue";
import NoteSweepPane from "@/components/NoteSweepPane.vue"; import NoteSweepPane from "@/components/NoteSweepPane.vue";
import StatusBadge from "@/components/StatusBadge.vue"; import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue"; import PriorityBadge from "@/components/PriorityBadge.vue";
import ScrollSentinel from "@/components/ScrollSentinel.vue";
import GraphView from "@/views/GraphView.vue"; import GraphView from "@/views/GraphView.vue";
import { import {
FileText, FileText,
@@ -193,7 +194,7 @@ function onClickOutsideNewNote(e: MouseEvent) {
const ID_BATCH = 100; // IDs fetched per server round-trip const ID_BATCH = 100; // IDs fetched per server round-trip
const INITIAL_CONTENT = 50; // items loaded on first render const INITIAL_CONTENT = 50; // items loaded on first render
const CONTENT_PAGE = 24; // items loaded per sentinel trigger const CONTENT_PAGE = 24; // items loaded each time the end of the feed nears
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
const items = ref<KnowledgeItem[]>([]); const items = ref<KnowledgeItem[]>([]);
@@ -214,11 +215,21 @@ const totalKnowledge = ref(0); // total matching items on server
const allIdsFetched = ref(false); // no more ID pages to fetch const allIdsFetched = ref(false); // no more ID pages to fetch
const idsFetching = ref(false); // ID batch request in-flight const idsFetching = ref(false); // ID batch request in-flight
const contentFetching = ref(false); // content batch request in-flight const contentFetching = ref(false); // content batch request in-flight
const sentinelVisible = ref(false); // updated by IntersectionObserver const moreFailed = ref(false); // the last page asked for did not load
const loading = computed(() => idsFetching.value || contentFetching.value); const loading = computed(() => idsFetching.value || contentFetching.value);
let fetchGen = 0; // incremented on each reset to invalidate stale responses let fetchGen = 0; // incremented on each reset to invalidate stale responses
const feedGen = ref(0); // fetchGen, readable by the template
// What the next page is keyed on: the feed it belongs to and how many of its ids
// have been loaded. Ids, not items: a batch can return fewer records than it
// asked for, and the feed must still move on. Empty once every id is loaded.
const nextCursor = computed(() =>
idQueue.value.length > 0 || !allIdsFetched.value
? `${feedGen.value}:${idOffset.value - idQueue.value.length}`
: "",
);
function buildFilterParams(): URLSearchParams { function buildFilterParams(): URLSearchParams {
const p = new URLSearchParams(); const p = new URLSearchParams();
@@ -244,7 +255,9 @@ async function fetchIdBatch(gen: number) {
idOffset.value += data.ids.length; idOffset.value += data.ids.length;
totalKnowledge.value = data.total; totalKnowledge.value = data.total;
if (!data.has_more) allIdsFetched.value = true; if (!data.has_more) allIdsFetched.value = true;
} catch { /* silent */ } } catch {
if (gen === fetchGen) moreFailed.value = true;
}
finally { if (gen === fetchGen) idsFetching.value = false; } finally { if (gen === fetchGen) idsFetching.value = false; }
} }
@@ -262,26 +275,38 @@ async function loadNextContent(gen: number, count: number) {
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) { if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
fetchIdBatch(gen); fetchIdBatch(gen);
} }
} catch { /* silent */ } } catch {
finally { // The ids go back to the front of the queue, so Try again asks for the
if (gen !== fetchGen) return; // same records rather than skipping past them.
contentFetching.value = false; if (gen === fetchGen) {
// Sentinel may still be visible if new items didn't push it off screen idQueue.value.unshift(...toLoad);
await nextTick(); moreFailed.value = true;
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
loadNextContent(gen, CONTENT_PAGE);
} }
} }
finally {
if (gen === fetchGen) contentFetching.value = false;
}
}
// The next page of the feed: more ids first when the queue has run dry.
async function loadMore() {
const gen = fetchGen;
moreFailed.value = false;
if (idQueue.value.length === 0) await fetchIdBatch(gen);
if (gen !== fetchGen) return;
await loadNextContent(gen, CONTENT_PAGE);
} }
async function reset() { async function reset() {
fetchGen++; fetchGen++;
feedGen.value = fetchGen;
const gen = fetchGen; const gen = fetchGen;
items.value = []; items.value = [];
idQueue.value = []; idQueue.value = [];
idOffset.value = 0; idOffset.value = 0;
totalKnowledge.value = 0; totalKnowledge.value = 0;
allIdsFetched.value = false; allIdsFetched.value = false;
moreFailed.value = false;
await fetchIdBatch(gen); await fetchIdBatch(gen);
if (gen !== fetchGen) return; if (gen !== fetchGen) return;
await loadNextContent(gen, INITIAL_CONTENT); await loadNextContent(gen, INITIAL_CONTENT);
@@ -294,23 +319,16 @@ async function fetchTags() {
} catch { /* silent */ } } catch { /* silent */ }
} }
async function resetAndReobserve() {
await reset();
await nextTick();
setupObserver();
}
function onSearchInput() { function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce); if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => resetAndReobserve(), 380); searchDebounce = setTimeout(() => reset(), 380);
} }
watch([activeType, sortMode], () => resetAndReobserve()); watch([activeType, sortMode], () => reset());
// Closing the sweep remounts the feed, and with it the scroll sentinel — a // Closing the sweep starts the feed over, so a note confirmed in the sweep is
// fresh element the old observer is not watching. Without this the list loads // shown as it now stands.
// its first page and then never loads another. watch(sweepActive, (open) => { if (!open) reset(); });
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); }); watch(activeTag, () => { fetchCounts(); reset(); });
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
// ─── Today bar ──────────────────────────────────────────────────────────────── // ─── Today bar ────────────────────────────────────────────────────────────────
@@ -377,24 +395,6 @@ function formatDate(iso: string): string {
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} }
// ─── Sentinel observer ────────────────────────────────────────────────────────
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
([entry]) => {
sentinelVisible.value = entry.isIntersecting;
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
},
{ root: null, rootMargin: "200px" }
);
observer.observe(sentinelEl.value);
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
@@ -403,14 +403,11 @@ onMounted(async () => {
fetchTags(); fetchTags();
fetchCounts(); fetchCounts();
fetchTodayBar(); fetchTodayBar();
await nextTick();
setupObserver();
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', onClickOutsideNewNote); document.removeEventListener('click', onClickOutsideNewNote);
if (searchDebounce) clearTimeout(searchDebounce); if (searchDebounce) clearTimeout(searchDebounce);
observer?.disconnect();
}); });
</script> </script>
@@ -583,10 +580,7 @@ onUnmounted(() => {
</div> </div>
<!-- The whole feed stands down while the sweep is open: two answers <!-- The whole feed stands down while the sweep is open: two answers
to two different questions on one screen is neither. Wrapped to two different questions on one screen is neither. -->
rather than given an extra v-if branch, because the scroll
sentinel lives inside the grid and the observer must not be left
holding a ref to something that never renders. -->
<template v-if="!sweepActive"> <template v-if="!sweepActive">
<!-- Loading / empty --> <!-- Loading / empty -->
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div> <div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
@@ -675,10 +669,12 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Sentinel — IntersectionObserver triggers next content batch --> <ScrollSentinel
<div ref="sentinelEl" class="scroll-sentinel"> :cursor="nextCursor"
<span v-if="contentFetching" class="sentinel-loading">Loading…</span> :loading="loading"
</div> :failed="moreFailed"
@more="loadMore"
/>
</div> </div>
</template> </template>
</div> </div>
@@ -1115,19 +1111,6 @@ onUnmounted(() => {
opacity: 0.85; opacity: 0.85;
} }
/* ── Sentinel ────────────────────────────────────────────── */
.scroll-sentinel {
grid-column: 1 / -1;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.sentinel-loading {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
/* ── Graph panel ─────────────────────────────────────────── */ /* ── Graph panel ─────────────────────────────────────────── */
.graph-panel { .graph-panel {
width: 500px; width: 500px;