Public: anyone can read this; the crew can change it.
Home: pressing a feed chip refreshes the feed region in place and says when it failed #1981
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1928-home-pressing-a-feed-chip-refreshes-the → main 4 commits fork 4 threads · 4 unresolved
src/web/studio/feed.ts +47 −6
| @@ -287,6 +287,7 @@ ${outboxJs} | ||
| 287 | 287 | if (!feed || typeof fetch === 'undefined') return; |
| 288 | 288 | var note = document.getElementById('feed-note'); |
| 289 | 289 | var outbox = null; |
| 290 | var problem = ''; | |
| 290 | 291 | try { outbox = forgeOutbox(window.localStorage, function (u, o) { return fetch(u, o); }); } catch (e) {} |
| 291 | 292 | function ago(ms) { |
| 292 | 293 | var m = Math.max(0, Math.round((Date.now() - ms) / 60000)); |
| @@ -302,6 +303,7 @@ ${outboxJs} | ||
| 302 | 303 | parts.push('Offline: this feed is from ' + ago(Number(note.getAttribute('data-rendered'))) + '.'); |
| 303 | 304 | var n = outbox ? outbox.size() : 0; |
| 304 | 305 | if (n > 0) parts.push(n + (n === 1 ? ' act is' : ' acts are') + ' waiting to send.'); |
| 306 | if (problem) parts.push(problem); | |
| 305 | 307 | note.textContent = parts.join(' '); |
| 306 | 308 | note.hidden = parts.length === 0; |
| 307 | 309 | } |
| @@ -327,14 +329,53 @@ ${outboxJs} | ||
| 327 | 329 | } |
| 328 | 330 | // Acting is instant: the card goes, the act is kept and sent. |
| 329 | 331 | function act(card, kind) { if (send(card, kind)) gone(card); } |
| 332 | // A chip flips at once; the saved filter then redraws the region in place | |
| 333 | // (the same fragment the live region pulls), keeping scroll and focus. A | |
| 334 | // refusal puts the chip back and says why in the note. | |
| 335 | function fail(c, was, why) { | |
| 336 | c.setAttribute('aria-pressed', was); | |
| 337 | problem = why; | |
| 338 | paint(); | |
| 339 | } | |
| 340 | function chip(c) { | |
| 341 | var was = c.getAttribute('aria-pressed'); | |
| 342 | var kind = c.getAttribute('data-chip'); | |
| 343 | var value = c.getAttribute('data-value'); | |
| 344 | c.setAttribute('aria-pressed', was === 'true' ? 'false' : 'true'); | |
| 345 | problem = ''; | |
| 346 | paint(); | |
| 347 | fetch('/api/v1/feed/chips', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, | |
| 348 | body: JSON.stringify({ kind: kind, value: value, hidden: was === 'true' }) }) | |
| 349 | .then(function (r) { | |
| 350 | if (r.ok) return refetch(kind, value); | |
| 351 | fail(c, was, r.status === 401 || r.status === 403 | |
| 352 | ? 'The filter was not saved because you are signed out. Sign in again and press the chip once more.' | |
| 353 | : 'The filter was not saved because the server refused it. Reload the page and try again.'); | |
| 354 | }) | |
| 355 | .catch(function () { fail(c, was, 'The filter was not saved because there is no connection. Check it and press the chip again.'); }); | |
| 356 | } | |
| 357 | function refetch(kind, value) { | |
| 358 | return fetch('/live/feed', { headers: { accept: 'text/html' }, credentials: 'same-origin' }) | |
| 359 | .then(function (r) { if (!r.ok) throw r; return r.text(); }) | |
| 360 | .then(function (text) { | |
| 361 | var holder = document.createElement('template'); | |
| 362 | holder.innerHTML = text; | |
| 363 | var fresh = holder.content.querySelector('#live-feed'); | |
| 364 | if (!fresh) return; | |
| 365 | var had = document.activeElement && document.activeElement.closest && document.activeElement.closest('[data-chip]'); | |
| 366 | feed.innerHTML = fresh.innerHTML; | |
| 367 | feed.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); | |
| 368 | if (had) { | |
| 369 | var all = feed.querySelectorAll('[data-chip]'); | |
| 370 | for (var i = 0; i < all.length; i++) | |
| 371 | if (all[i].getAttribute('data-chip') === kind && all[i].getAttribute('data-value') === value) all[i].focus(); | |
| 372 | } | |
| 373 | }) | |
| 374 | .catch(function () { problem = 'The filter was saved, but the feed could not be refreshed. Reload the page to see it.'; paint(); }); | |
| 375 | } | |
A response with no #live-feed (for example a sign-in page) returns silently. Set |
||
| 330 | 376 | feed.addEventListener('click', function (e) { |
| 331 | 377 | var c = e.target && e.target.closest ? e.target.closest('[data-chip]') : null; |
| 332 | if (c) { | |
| 333 | fetch('/api/v1/feed/chips', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, | |
| 334 | body: JSON.stringify({ kind: c.getAttribute('data-chip'), value: c.getAttribute('data-value'), hidden: c.getAttribute('aria-pressed') === 'true' }) }) | |
| 335 | .then(function (r) { if (r.ok) location.reload(); }); | |
| 336 | return; | |
| 337 | } | |
| 378 | if (c) { chip(c); return; } | |
| 338 | 379 | var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null; |
| 339 | 380 | var card = t && t.closest('.fcard'); |
| 340 | 381 | if (!card) return; |
test/feed-chips.test.ts +21
| @@ -18,6 +18,7 @@ import { mcpTool } from "./helpers/mcp.ts"; | ||
| 18 | 18 | import { chipHides } from "../src/feed/chips.ts"; |
| 19 | 19 | import type { FeedKind } from "../src/feed/items.ts"; |
| 20 | 20 | import { readFeed } from "../src/feed/feed.ts"; |
| 21 | import { feedJs } from "../src/web/studio/feed.ts"; | |
| 21 | 22 | |
| 22 | 23 | const ALL: FeedKind[] = [ |
| 23 | 24 | "run", "ask", "pull", "decision", "map", "cost", "tell", "reply", "look", "milestone", "choose", "release", |
| @@ -175,6 +176,26 @@ describe("the chips of a forge", () => { | ||
| 175 | 176 | setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false); |
| 176 | 177 | }); |
| 177 | 178 | |
| 179 | test("a pressed chip refreshes the region in place and says when it failed", async () => { | |
| 180 | const handler = feedJs.slice(feedJs.indexOf("function chip(c)"), feedJs.indexOf("feed.addEventListener('click'")); | |
This slices the script source and checks substrings, so it would still pass if the handler were wrong. Run the handler against a stubbed fetch and assert the chip rollback and the note. |
||
| 181 | expect(handler).not.toContain("location.reload"); | |
outdated
This slices the script source and checks substrings, so it passes if the failure branch is dead code. Run the handler against a stub DOM/fetch with a 403 and assert the chip returns to its prior aria-pressed and the note shows the message. |
||
| 182 | expect(handler).not.toMatch(/b\.message|b\.error|\.statusText/); | |
| 183 | expect(handler).toContain("Sign in again"); | |
| 184 | expect(handler).toContain("/live/feed"); | |
| 185 | expect(handler).toContain("aria-pressed"); | |
| 186 | expect(feedJs).toContain("problem"); | |
| 187 | const session = createSession(app.db, bob.id); | |
| 188 | const cookie = { cookie: `forge_session=${session.token}` }; | |
| 189 | await call(bobToken, "POST", "/api/v1/feed/chips", { kind: "kind", value: "crew", hidden: true }); | |
| 190 | const region = await (await fetch(`${servers.url}/live/feed`, { headers: cookie })).text(); | |
| 191 | expect(region).toMatch(/data-chip="kind"\s+data-value="crew"\s+aria-pressed="false"/); | |
| 192 | expect(region).not.toContain('data-kind="look"'); | |
| 193 | await call(bobToken, "POST", "/api/v1/feed/chips", { kind: "kind", value: "crew", hidden: false }); | |
| 194 | const back = await (await fetch(`${servers.url}/live/feed`, { headers: cookie })).text(); | |
| 195 | expect(back).toMatch(/data-chip="kind"\s+data-value="crew"\s+aria-pressed="true"/); | |
| 196 | expect(back).toContain('data-kind="look"'); | |
| 197 | }); | |
| 198 | ||
| 178 | 199 | test("MCP feed applies the saved filter and takes the same filter as arguments", async () => { |
| 179 | 200 | const tool = (token: string, name: string, args: unknown = {}) => |
| 180 | 201 | mcpTool(servers.url, token, name, args); |
Two quick taps on the same chip send two POSTs and two refetches; whichever response lands last wins. Guard with an in-flight flag per chip or drop stale refetches.