From b0dcd5c7682f8ff0e695ca5bbda2c595a3f023ee Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 25 Oct 2026 16:24:49 -0400 Subject: [PATCH] fix(web): keep an agent's newest line when a reread announces older ones A repository read again announces its newest line first, so the canvas ended on the oldest. The agent's line, its callout and its card's list now follow each line's createdAt, as /lanes does. Co-Authored-By: Claude Opus 5.5 Change-Id: I100e53bda53eb1f96d37c16852c9d9a145a00ecf --- web/detail.js | 28 ++++++++++++++++++++++++---- web/dev/test/canvas.test.mjs | 15 +++++++++++++++ web/graph/feed.js | 24 +++++++++++++++++------- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/web/detail.js b/web/detail.js index 089484b..5473ea3 100644 --- a/web/detail.js +++ b/web/detail.js @@ -51,6 +51,9 @@ const Detail = (() => { let weeksAsked = 0; /** The highest stamp already in the scrobble list. */ let drawn = 0; + /** Whether a line was recorded among the subject's older ones, so the + * list is drawn again whole rather than added to. */ + let redraw = false; function el(tag, className, text) { const node = document.createElement(tag); @@ -770,6 +773,12 @@ const Detail = (() => { */ function renderScrobbles() { const lines = history.get(subject) || []; + if (redraw) { + redraw = false; + drawn = 0; + nodes.scrobbles.textContent = ""; + nodes.scrobbles.append(nodes.empty); + } nodes.empty.hidden = lines.length > 0; renderPostmark(subjectKind === "person" || !lines.length ? null : lines[lines.length - 1].wall); @@ -793,7 +802,7 @@ const Detail = (() => { // list and never above the newest line. nodes.scrobbles.insertBefore(item, nodes.scrobbles.firstChild); } - drawn = lines.length ? lines[lines.length - 1].stamp : drawn; + drawn = stamped; // The empty note lives at the bottom of the list and is not a line, so // the oldest line is whatever sits above it. @@ -1428,7 +1437,7 @@ const Detail = (() => { lines = []; } stamped += 1; - lines.push({ + const kept = { uri: line.uri || "", emoji: line.emoji || "", text: line.text || "", @@ -1442,7 +1451,13 @@ const Detail = (() => { // the backlog is as old as that, not as old as the page. wall: line.written || Date.now(), stamp: stamped, - }); + }; + // Kept in the order the lines were written, not the order they came: a + // repository read again announces its newest first. + let at = lines.length; + while (at > 0 && lines[at - 1].wall > kept.wall) at -= 1; + lines.splice(at, 0, kept); + if (at < lines.length - 1 && did === subject) redraw = true; if (lines.length > HISTORY) lines.splice(0, lines.length - HISTORY); history.set(did, lines); while (history.size > TRACKED) { @@ -1453,6 +1468,11 @@ const Detail = (() => { if (did === subject && isOpen()) renderScrobbles(); } + /** The lines kept for `did`, oldest first. */ + function linesOf(did) { + return (history.get(did) || []).slice(); + } + /** Drops an agent's history, for when its account is deleted. */ function forget(did) { history.delete(did); @@ -1987,5 +2007,5 @@ const Detail = (() => { /** Whether a week's zine is open over the board: its keys are its own. */ const issueOpen = () => typeof Issue !== "undefined" && Issue.isOpen(); - return { mount, query, shelfOf, weekLinks, blueskyPost, issueOpen, show, showPerson, hide, isOpen, refresh, record, unrecord, forget, pdslsURL, dashboardURL, peek, peekOperator, peekServer, peekGroup, action, plainMention, hints, setHint, unpeek, hover, peeking, enterPeek, hashtags, modelCard, agentCard, bioParts }; + return { mount, query, shelfOf, weekLinks, blueskyPost, issueOpen, show, showPerson, hide, isOpen, refresh, record, unrecord, linesOf, forget, pdslsURL, dashboardURL, peek, peekOperator, peekServer, peekGroup, action, plainMention, hints, setHint, unpeek, hover, peeking, enterPeek, hashtags, modelCard, agentCard, bioParts }; })(); diff --git a/web/dev/test/canvas.test.mjs b/web/dev/test/canvas.test.mjs index 0a440c7..1d7ede7 100644 --- a/web/dev/test/canvas.test.mjs +++ b/web/dev/test/canvas.test.mjs @@ -502,6 +502,21 @@ test("only an account whose scrobbles named a model has a model on its card: a h assert.equal(view.state.agents.get(host.did).model, undefined); }); +test("a repository read again, newest first, leaves the newest line as the agent's and flies none of the older ones", () => { + const { appview: view, Detail } = page(); + for (const event of [person, server, host, session]) view.apply(event, false); + const line = (n) => ({ kind: "scrobble", uri: `at://${session.did}/com.vibescrobble.scrobble/${n}`, did: session.did, text: `line ${n}`, emoji: "🧭", written: Date.now() - (10 - n) * 60e3 }); + // Its newest line live, then its repository's listing, newest first. + for (const n of [9, 8, 7, 6, 5]) view.apply(line(n), true); + const agent = view.state.agents.get(session.did); + assert.equal(agent.text, "line 9"); + assert.deepEqual(Array.from(view.state.floats, (float) => float.text), ["line 9"]); + assert.deepEqual(Array.from(Detail.linesOf(session.did), (kept) => kept.text), ["line 5", "line 6", "line 7", "line 8", "line 9"]); + // A line written since is the agent's. + view.apply(line(10), true); + assert.equal(agent.text, "line 10"); +}); + test("hovering an account whose own card is open opens nothing; hovering another still does", () => { const { appview: view, Detail } = page(); for (const event of [person, server, host, session, scrobble(session.did)]) view.apply(event, false); diff --git a/web/graph/feed.js b/web/graph/feed.js index b373bec..b08efa1 100644 --- a/web/graph/feed.js +++ b/web/graph/feed.js @@ -540,19 +540,27 @@ function addScrobble(event, live) { ? -Infinity : state.now - 20 - Math.random() * 900; agent.saidAt = Math.max(agent.saidAt, said); - agent.emoji = event.emoji; - agent.text = event.text; - agent.lastUri = event.uri; + // The agent's line is the one written last, as /lanes has it, and not the + // one heard last: a repository read again announces its newest first. A + // line from the future, or one that says no time, is read as now. + const lineAt = Math.min(event.written || Date.now(), Date.now()); + const newest = !(lineAt < agent.lineAt); + if (newest) { + agent.emoji = event.emoji; + agent.text = event.text; + agent.lastUri = event.uri; + agent.lineAt = lineAt; + } burned(agent, event, live, written); // Unknown levels are carried rather than rejected: the lexicon calls this // an open set, and an effort this page has not heard of should still draw // as something rather than vanish. - if (event.effort) agent.effort = event.effort; + if (newest && event.effort) agent.effort = event.effort; // A line that names no model leaves the one the agent last named. - if (event.model) agent.model = event.model; + if (newest && event.model) agent.model = event.model; // And the same for the repository it is working in. if (event.repo && event.repo.url) { - agent.repo = event.repo; + if (newest) agent.repo = event.repo; Linkify.know(event.repo); } // Handed over whether or not a panel is open: an agent somebody clicks on @@ -580,7 +588,8 @@ function addScrobble(event, live) { // about a past nobody watched. if (event.written) hear(Math.min(event.written, Date.now())); else if (live) hear(Date.now()); - if (!live) return; + // An older line read again is history, and flies over nobody. + if (!live || !newest) return; // An agent's newer line replaces its older one: a small swarm otherwise // fills the plate with one agent's last three sentences at once. for (const float of state.floats) { @@ -629,6 +638,7 @@ function removeScrobble(uri) { agent.emoji = ""; agent.text = ""; agent.lastUri = null; + agent.lineAt = undefined; } } state.floats = state.floats.filter((float) => float.uri !== uri); -- knotmirror 0.0.1