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
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:
@@ -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>
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
||||
} from "@/api/family";
|
||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
import { recordHref } from "@/utils/recordHref";
|
||||
@@ -36,6 +37,8 @@ const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const moreDecisions = 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.
|
||||
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
||||
@@ -63,7 +66,9 @@ async function load() {
|
||||
criteria.value = ideaData.criteria;
|
||||
conflictOrder.value = ideaData.conflict_order ?? [];
|
||||
decisions.value = log;
|
||||
decisionOffset.value = log.length;
|
||||
moreDecisions.value = log.length === PAGE;
|
||||
moreFailed.value = false;
|
||||
} catch {
|
||||
// Said out loud: a log that failed to load must not read as "nothing
|
||||
// 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() {
|
||||
loadingMore.value = true;
|
||||
moreFailed.value = false;
|
||||
try {
|
||||
const next = await listFamilyDecisions(PAGE, decisions.value.length);
|
||||
decisions.value.push(...next);
|
||||
const next = await listFamilyDecisions(PAGE, decisionOffset.value);
|
||||
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;
|
||||
} catch (e: unknown) {
|
||||
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
|
||||
} catch {
|
||||
moreFailed.value = true;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
@@ -343,9 +354,12 @@ onMounted(load);
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
|
||||
{{ loadingMore ? "Loading…" : "Older decisions" }}
|
||||
</button>
|
||||
<ScrollSentinel
|
||||
:cursor="moreDecisions ? String(decisionOffset) : ''"
|
||||
:loading="loadingMore"
|
||||
:failed="moreFailed"
|
||||
@more="loadMore"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { apiGet } from "@/api/client";
|
||||
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 StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import {
|
||||
FileText,
|
||||
@@ -193,7 +194,7 @@ function onClickOutsideNewNote(e: MouseEvent) {
|
||||
|
||||
const ID_BATCH = 100; // IDs fetched per server round-trip
|
||||
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 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 idsFetching = ref(false); // ID 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);
|
||||
|
||||
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 {
|
||||
const p = new URLSearchParams();
|
||||
@@ -244,7 +255,9 @@ async function fetchIdBatch(gen: number) {
|
||||
idOffset.value += data.ids.length;
|
||||
totalKnowledge.value = data.total;
|
||||
if (!data.has_more) allIdsFetched.value = true;
|
||||
} catch { /* silent */ }
|
||||
} catch {
|
||||
if (gen === fetchGen) moreFailed.value = true;
|
||||
}
|
||||
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) {
|
||||
fetchIdBatch(gen);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally {
|
||||
if (gen !== fetchGen) return;
|
||||
contentFetching.value = false;
|
||||
// Sentinel may still be visible if new items didn't push it off screen
|
||||
await nextTick();
|
||||
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
|
||||
loadNextContent(gen, CONTENT_PAGE);
|
||||
} catch {
|
||||
// The ids go back to the front of the queue, so Try again asks for the
|
||||
// same records rather than skipping past them.
|
||||
if (gen === fetchGen) {
|
||||
idQueue.value.unshift(...toLoad);
|
||||
moreFailed.value = true;
|
||||
}
|
||||
}
|
||||
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() {
|
||||
fetchGen++;
|
||||
feedGen.value = fetchGen;
|
||||
const gen = fetchGen;
|
||||
items.value = [];
|
||||
idQueue.value = [];
|
||||
idOffset.value = 0;
|
||||
totalKnowledge.value = 0;
|
||||
allIdsFetched.value = false;
|
||||
moreFailed.value = false;
|
||||
await fetchIdBatch(gen);
|
||||
if (gen !== fetchGen) return;
|
||||
await loadNextContent(gen, INITIAL_CONTENT);
|
||||
@@ -294,23 +319,16 @@ async function fetchTags() {
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function resetAndReobserve() {
|
||||
await reset();
|
||||
await nextTick();
|
||||
setupObserver();
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
||||
searchDebounce = setTimeout(() => reset(), 380);
|
||||
}
|
||||
|
||||
watch([activeType, sortMode], () => resetAndReobserve());
|
||||
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
|
||||
// fresh element the old observer is not watching. Without this the list loads
|
||||
// its first page and then never loads another.
|
||||
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
|
||||
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||
watch([activeType, sortMode], () => reset());
|
||||
// Closing the sweep starts the feed over, so a note confirmed in the sweep is
|
||||
// shown as it now stands.
|
||||
watch(sweepActive, (open) => { if (!open) reset(); });
|
||||
watch(activeTag, () => { fetchCounts(); reset(); });
|
||||
|
||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -377,24 +395,6 @@ function formatDate(iso: string): string {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -403,14 +403,11 @@ onMounted(async () => {
|
||||
fetchTags();
|
||||
fetchCounts();
|
||||
fetchTodayBar();
|
||||
await nextTick();
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideNewNote);
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -583,10 +580,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- The whole feed stands down while the sweep is open: two answers
|
||||
to two different questions on one screen is neither. Wrapped
|
||||
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. -->
|
||||
to two different questions on one screen is neither. -->
|
||||
<template v-if="!sweepActive">
|
||||
<!-- Loading / empty -->
|
||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||
@@ -675,10 +669,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel — IntersectionObserver triggers next content batch -->
|
||||
<div ref="sentinelEl" class="scroll-sentinel">
|
||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
||||
</div>
|
||||
<ScrollSentinel
|
||||
:cursor="nextCursor"
|
||||
:loading="loading"
|
||||
:failed="moreFailed"
|
||||
@more="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1115,19 +1111,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
width: 500px;
|
||||
|
||||
Reference in New Issue
Block a user