// Multi-neighbor lazy-load UI: a per-neighbor progress list with a // "cancel the slow one" button that appears once a single straggler is // left running. // // State maintained for the duration of one fetchNeighborsLazy call: // progressMap : did -> { handle, status, message } // order : [did, ...] preserves rank order so the list is stable // controllers : did -> AbortController (one per neighbor) // stragglerTimer: timeout id; set when only one neighbor remains, // shows the cancel button after STRAGGLER_CANCEL_DELAY_MS. const STRAGGLER_CANCEL_DELAY_MS = 15000; const COMPACT_LIST_THRESHOLD = 5; // collapse terminal rows once list exceeds this function renderNeighborProgressList(progressMap, order) { const el = $("stage-progress-list"); if (!el) return; el.hidden = false; // Once we have more than a handful of neighbors, completed // (done/cancelled/failed) rows are removed entirely — the list stays // focused on whatever is actively fetching plus a small summary tail. const compact = order.length > COMPACT_LIST_THRESHOLD; const isTerminal = (s) => s === "done" || s === "cancelled" || s === "failed"; const visible = compact ? order.filter((did) => { const e = progressMap.get(did); return e && !isTerminal(e.status); }) : order; const parts = []; if (compact) { let doneN = 0, cancelN = 0, failN = 0; for (const did of order) { const s = progressMap.get(did)?.status; if (s === "done") doneN++; else if (s === "cancelled") cancelN++; else if (s === "failed") failN++; } const total = order.length; const finished = doneN + cancelN + failN; if (finished) { const bits = []; if (doneN) bits.push(`${doneN} done`); if (cancelN) bits.push(`${cancelN} cancelled`); if (failN) bits.push(`${failN} failed`); parts.push( `
` + `✓` + `${finished}/${total} finished` + `${bits.join(" · ")}` + `
`, ); } } for (const did of visible) { const entry = progressMap.get(did); if (!entry) continue; const icon = entry.status === "done" ? "✓" : entry.status === "cancelled" ? "✕" : entry.status === "failed" ? "!" : "·"; parts.push( `
` + `${icon}` + `@${escapeHtml(entry.handle)}` + `${escapeHtml(entry.message || "")}` + `
`, ); } el.innerHTML = parts.join(""); } function clearStageActions() { const el = $("stage-progress-actions"); if (el) el.innerHTML = ""; } function showStragglerCancelButton(did, handle, onCancel) { const el = $("stage-progress-actions"); if (!el) return; el.innerHTML = ``; $("cancel-straggler-btn").addEventListener("click", () => { el.innerHTML = ""; // hide immediately so it can't be double-clicked onCancel(); }); } async function loadNeighborsWithProgress(world, missingDids) { const total = missingDids.length; setStageProgress(`Loading ${total} neighbor${total > 1 ? "s" : ""}…`, "starting", null); const progressMap = new Map(); // did -> {handle, status, message} const order = [...missingDids]; const controllers = new Map(); // did -> AbortController for (const did of missingDids) { controllers.set(did, new AbortController()); progressMap.set(did, { handle: "(pending)", status: "started", message: "queued" }); } // Stub handles from the existing user table so the list isn't blank // before the first progress event fires. const userByDid = new Map(world.users.map((u) => [u.did, u])); for (const did of missingDids) { const u = userByDid.get(did); if (u && u.handle) progressMap.get(did).handle = u.handle; } renderNeighborProgressList(progressMap, order); clearStageActions(); let stragglerTimer = null; function checkStraggler() { // Find dids that are still "started" or "progress" (not done/cancelled/failed). const stillRunning = order.filter((did) => { const e = progressMap.get(did); return e && (e.status === "started" || e.status === "progress"); }); if (stillRunning.length === 1) { if (stragglerTimer) return; // already scheduled const lastDid = stillRunning[0]; stragglerTimer = setTimeout(() => { stragglerTimer = null; // Re-check at firing time — it may have finished while we waited. const e = progressMap.get(lastDid); if (!e || (e.status !== "started" && e.status !== "progress")) return; showStragglerCancelButton(lastDid, e.handle, () => { const ctl = controllers.get(lastDid); if (ctl) ctl.abort(); }); }, STRAGGLER_CANCEL_DELAY_MS); } else { // More than one (or zero) still running — kill any pending timer + button. if (stragglerTimer) { clearTimeout(stragglerTimer); stragglerTimer = null; } clearStageActions(); } } try { const forceRefetch = !!$("force-refetch")?.checked; await window.bsFarmFetch.fetchNeighborsLazy( world, missingDids, { neighborEngagementCap: 40, neighborConcurrency: 3, forceRefetch, signalFor: (did) => controllers.get(did)?.signal, onNeighbor: ({ did, handle, status, message }) => { const cur = progressMap.get(did) || {}; progressMap.set(did, { handle: handle || cur.handle || "(unknown)", status, message: message || "", }); renderNeighborProgressList(progressMap, order); checkStraggler(); }, }, (msg) => { // Legacy aggregate progress — keep updating the title/body so users // who care about overall completion still see motion. setStageProgress(`Loading ${total} neighbor${total > 1 ? "s" : ""}…`, msg, null); }, ); } finally { if (stragglerTimer) clearTimeout(stragglerTimer); clearStageActions(); // Leave the list visible for a beat so the user sees the final state; // it'll get hidden when we transition to "simulating". const list = $("stage-progress-list"); if (list) { list.hidden = true; list.innerHTML = ""; } } }