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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1928-home-pressing-a-feed-chip-refreshes-the → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/1928-home-pressing-a-feed-chip-refreshes-the was merged into main as e7f0a79.

Review

Sign in to review this change.
2 files changed +68 −6
src/web/studio/feed.ts +47 −6
@@ -287,6 +287,7 @@ ${outboxJs}
287287 if (!feed || typeof fetch === 'undefined') return;
288288 var note = document.getElementById('feed-note');
289289 var outbox = null;
290 var problem = '';
290291 try { outbox = forgeOutbox(window.localStorage, function (u, o) { return fetch(u, o); }); } catch (e) {}
291292 function ago(ms) {
292293 var m = Math.max(0, Math.round((Date.now() - ms) / 60000));
@@ -302,6 +303,7 @@ ${outboxJs}
302303 parts.push('Offline: this feed is from ' + ago(Number(note.getAttribute('data-rendered'))) + '.');
303304 var n = outbox ? outbox.size() : 0;
304305 if (n > 0) parts.push(n + (n === 1 ? ' act is' : ' acts are') + ' waiting to send.');
306 if (problem) parts.push(problem);
305307 note.textContent = parts.join(' ');
306308 note.hidden = parts.length === 0;
307309 }
@@ -327,14 +329,53 @@ ${outboxJs}
327329 }
328330 // Acting is instant: the card goes, the act is kept and sent.
329331 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.'
outdated

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.

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 problem to the refresh-failed note here, as the catch does.

330376 feed.addEventListener('click', function (e) {
331377 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; }
338379 var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null;
339380 var card = t && t.closest('.fcard');
340381 if (!card) return;
test/feed-chips.test.ts +21
@@ -18,6 +18,7 @@ import { mcpTool } from "./helpers/mcp.ts";
1818 import { chipHides } from "../src/feed/chips.ts";
1919 import type { FeedKind } from "../src/feed/items.ts";
2020 import { readFeed } from "../src/feed/feed.ts";
21 import { feedJs } from "../src/web/studio/feed.ts";
2122
2223 const ALL: FeedKind[] = [
2324 "run", "ask", "pull", "decision", "map", "cost", "tell", "reply", "look", "milestone", "choose", "release",
@@ -175,6 +176,26 @@ describe("the chips of a forge", () => {
175176 setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false);
176177 });
177178
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
178199 test("MCP feed applies the saved filter and takes the same filter as arguments", async () => {
179200 const tool = (token: string, name: string, args: unknown = {}) =>
180201 mcpTool(servers.url, token, name, args);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs