Public: anyone can read this; the crew can change it.

The feed folds a run of identical crew posts into one line that opens, so the timeline reads as people first #12335

Merged opened this

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

Merged

scout/forge:scout/12326-the-feed-folds-a-run-of-identical-crew-p → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/12326-the-feed-folds-a-run-of-identical-crew-p was merged into main as e31b90f.

Review

Sign in to review this change.
12 files changed +298 −13
docs/design/areas/feed-fold.md added +4
@@ -0,0 +1,4 @@
1 # Feed fold
2
3 `.feed-fold`: a crew member's burst of runs and pulls as one `<details>` in a feed card; the summary is the line
4 ("scout: 5 runs, 4 pulls"), opening it lists the members, each linking to its own page. No script.
docs/features/feed-fold.md added +16
@@ -0,0 +1,16 @@
1 # Feed fold
2
3 Three or more runs and pulls from one crew member inside an hour fold into one line, "scout: 5 runs, 4 pulls", so the
4 timeline reads as people first. A person's post, a failure, a change asked for and an ask never fold.
5
6 **Web:** open `/feed`. The line is a disclosure; press it to list the members, each linking to its own run or pull.
7 Each member keeps its own words, including "(told by bob, by scout)": a burst has many tellers, so the told-by
8 text lives on the member lines, not on the folded line. Up to 50 members show, then "and N more".
9
10 **Terminal:** open the Feed screen (`ctrl-k`, "Feed"). The folded line reads the same; with the cursor on it,
11 `enter` lists the members and `esc` closes the list.
12
13 Dismissing a folded line holds as the burst grows (its id is its first member's).
14
15 Pinned by `test/feed-fold-bursts.test.ts` (the fold), `test/tui-feed-fold.test.ts` (the terminal) and
16 `test/told-by.test.ts` (told-by on the lines); design note in `docs/design/areas/feed-fold.md`.
src/feed/feed.ts +7 −4
@@ -13,7 +13,7 @@ import { dismissedItems, mutedRepos } from "../core/interactions.ts";
1313 import { affinityFor } from "./affinity.ts";
1414 import { canRead, getRepoById } from "../core/repos.ts";
1515 import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts";
16 import { getUserById, type User } from "../core/users.ts";
16 import { getUserById, getUsersByIds, type User } from "../core/users.ts";
1717 import { candidatesFor, quietRunsFor, type Candidate } from "./items.ts";
1818 import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
@@ -209,9 +209,12 @@ export function readFeedPage(
209209 !muted.has(c.repoId)),
210210 );
211211 // After every filter, so a burst folds only what the viewer would see.
212 const pageable = foldBursts(
213 options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates,
214 ).filter((c) => !dismissed.has(c.id)); // a folded post is dismissed under its own id
212 const visible = options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates;
213 const actors = [...new Set(visible.flatMap((c) => (c.actorId === null ? [] : [c.actorId])))];
214 const crew = new Set(
215 [...getUsersByIds(db, actors).values()].filter((u) => u.kind === "agent").map((u) => u.id),
216 );
217 const pageable = foldBursts(visible, crew).filter((c) => !dismissed.has(c.id)); // a folded post is dismissed under its own id
215218 const cursor = options.cursor;
216219 // Without a cursor only the page and one more (to know there is a next) are placed.
217220 let ranked = rank(
src/feed/fold.ts +77 −1
@@ -41,7 +41,83 @@ export interface Foldable {
4141 * Replaces each burst with one candidate that ranks where its newest member
4242 * would (its time) and keeps the rest as they were, in order.
4343 */
44 export function foldBursts(candidates: readonly Candidate[]): Candidate[] {
44 export function foldBursts(
45 candidates: readonly Candidate[],
46 crew: ReadonlySet<string> = new Set(),
47 ): Candidate[] {
48 return foldCrew(foldIssues(candidates), crew);
49 }
50
51 /** Three or more crew runs and pulls inside an hour of the first fold into one line that opens. */
52 export const CREW_FOLD = { min: 3, windowMs: 60 * 60_000, shown: 50 } as const;
53
54 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`;
55
56 /**
57 * A crew member's burst of runs and pulls becomes one post, `scout: 5 runs, 4 pulls`,
58 * whose lines are the members, each at its own permalink. A person's post, a failure,
59 * a change asked for and an ask are never members: they stay in view.
60 */
61 export function foldCrew(candidates: readonly Candidate[], crew: ReadonlySet<string>): Candidate[] {
62 if (crew.size === 0) return [...candidates];
63 const by = new Map<string, Candidate[]>();
64 for (const c of candidates) {
65 if (c.actorId === null || !crew.has(c.actorId) || c.actor === null) continue;
66 if ((c.kind !== "run" && c.kind !== "pull") || c.urgency > 0 || c.ask !== undefined) continue;
67 const list = by.get(c.actorId);
68 if (list) list.push(c);
69 else by.set(c.actorId, [c]);
70 }
71 const folded = new Map<string, Candidate>();
72 const gone = new Set<string>();
73 for (const list of by.values()) {
74 const sorted = [...list].sort(
75 (a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.id < b.id ? -1 : 1),
76 );
77 let burst: Candidate[] = [];
78 const close = () => {
79 if (burst.length >= CREW_FOLD.min) {
80 for (const c of burst) gone.add(c.id);
81 folded.set(burst[burst.length - 1]!.id, joinCrew(burst));
82 }
83 burst = [];
84 };
85 for (const c of sorted) {
86 if (burst.length > 0 && Date.parse(c.at) - Date.parse(burst[0]!.at) > CREW_FOLD.windowMs) close();
87 burst.push(c);
88 }
89 close();
90 }
91 const out: Candidate[] = [];
92 for (const c of candidates) {
93 const one = folded.get(c.id);
94 if (one) out.push(one);
95 else if (!gone.has(c.id)) out.push(c);
96 }
97 return out;
98 }
99
100 function joinCrew(burst: Candidate[]): Candidate {
101 const newest = burst[burst.length - 1]!;
102 const runs = burst.filter((c) => c.kind === "run").length;
103 const pulls = burst.length - runs;
104 const counts = [runs > 0 ? plural(runs, "run") : "", pulls > 0 ? plural(pulls, "pull") : ""].filter(Boolean);
105 const { fold: _fold, ...rest } = newest;
106 const lines = [...burst].reverse().map((c) => ({ text: c.what, link: c.link }));
107 return {
108 ...rest,
109 // The first member's id: it does not move as the burst grows, so a dismissal holds.
110 id: `fold:crew:${burst[0]!.id.replace(/^[a-z]+:/, "")}`,
111 kind: pulls === 0 ? "run" : runs === 0 ? "pull" : newest.kind,
112 what: `${newest.actor}: ${counts.join(", ")}`,

The newest member reason (told by ...) is dropped on fold; carry it or justify it.

113 said: `${newest.actor}: ${counts.join(", ")}`,
114 lines: lines.slice(0, CREW_FOLD.shown),
115 more: Math.max(0, lines.length - CREW_FOLD.shown),
116 opens: true,
117 };
118 }
119
120 function foldIssues(candidates: readonly Candidate[]): Candidate[] {
45121 const buckets = new Map<string, Candidate[]>();
46122 for (const c of candidates) {
47123 if (c.fold === undefined || c.actorId === null) continue;
src/feed/items.ts +2
@@ -84,6 +84,8 @@ export interface Candidate {
8484 /** On a folded post: the first titles, each linking to its own thing, and how many more. */
8585 readonly lines?: readonly { readonly text: string; readonly link: string }[];
8686 readonly more?: number;
87 /** A folded crew burst: its lines open in place (`<details>`) instead of showing. */
88 readonly opens?: boolean;
8789 /** For a look: the picture's environment and commit; the page finds the thumbnail. */
8890 readonly look?: FeedLook;
8991 }
src/feed/rank.ts +3
@@ -29,6 +29,8 @@ export interface FeedItem {
2929 /** A folded post: its first titles with their links, and how many more. */
3030 readonly lines?: readonly { readonly text: string; readonly link: string }[];
3131 readonly more?: number;
32 /** A folded crew burst: its lines open in place. */
33 readonly opens?: boolean;
3234 /** For a look: which picture it is of. */
3335 readonly look?: FeedLook;
3436 /** For a post about a pull or an issue that is over: how it ended. Absent while open. */
@@ -165,6 +167,7 @@ export function rank(
165167 score: Math.round(bestScore * 1e6) / 1e6,
166168 ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }),
167169 ...(s!.c.lines === undefined ? {} : { lines: s!.c.lines, more: s!.c.more ?? 0 }),
170 ...(s!.c.opens === true ? { opens: true } : {}),
168171 ...(s!.c.look === undefined ? {} : { look: s!.c.look }),
169172 });
170173 }
src/tui/screens/feed.ts +19 −2
@@ -4,7 +4,7 @@
44 *
55 * A reply is a comment on the issue or pull the post links to: the same
66 * `addCommentThrough` the issue and pull screens call, offered on the same
7 * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes
7 * posts to the same people as the page's Reply (`feed/reply.ts`). `enter` on a folded burst lists its members; `l` likes
88 * the post under the cursor and a second press takes it back: the page's
99 * Like, behind the same `readInteraction` check (`feed/likes.ts`); `h` lists
1010 * who liked or unliked it and when, newest first, behind that check too. Twenty
@@ -91,6 +91,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
9191 };
9292
9393 /** `h`: the like ledger of the post under the cursor (who, act, when), newest first, `m` for older rows. */
94 /** `enter` on a folded burst: its members, one to a line (the page's opened `<details>`). */
95 let members: FeedItem | null = null;
9496 let history: { item: string; lines: string[]; oldest: number; more: boolean } | null = null;
9597 const showHistory = (more = false): void => {
9698 const item = items[state.index];
@@ -131,6 +133,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
131133 return [
132134 binding(["j", "k"], "move"),
133135 ...(selected() === undefined ? [] : [binding(["r"], "reply")]),
136 ...(items[state.index]?.opens === true ? [binding(["enter"], "open the burst")] : []),
134137 ...(items.length === 0 ? [] : [binding(["l"], "like")]),
135138 ...(items.length === 0 ? [] : [binding(["h"], "who liked")]),
136139 ...(next === null ? [] : [binding(["n"], "next page")]),
@@ -144,6 +147,14 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
144147 emptyState(s, area, c, { title: EMPTY_FEED });
145148 return;
146149 }
150 if (members !== null) {
151 s.text(area.x, area.y, truncate(oneLine(members.what), area.w), style(c.fg, c.bg), area.x + area.w);
152 const all = [...(members.lines ?? []).map((l) => l.text), ...(members.more ? [`and ${members.more} more`] : [])];
153 all.slice(0, Math.max(0, area.h - 3)).forEach((line, i) =>
154 s.text(area.x + 1, area.y + 2 + i, truncate(oneLine(line), area.w - 1), style(c.mutedFg, c.bg), area.x + area.w));
155 s.text(area.x, area.y + area.h - 1, truncate("esc back", area.w), style(c.mutedFg, c.bg), area.x + area.w);
156 return;
157 }
147158 if (history !== null) {
148159 s.text(area.x, area.y, truncate("Likes on this post, newest first", area.w), style(c.fg, c.bg), area.x + area.w);
149160 const body = history.lines.length === 0 ? ["nobody has liked it yet"] : history.lines;
@@ -170,6 +181,11 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
170181 else if (out === "cancel") composer.close();
171182 return true;
172183 }
184 if (members !== null) {
185 if (e.key === "esc" || e.key === "q") members = null;
186 ctx.invalidate();
187 return true;
188 }
173189 if (history !== null) {
174190 if (e.key === "esc" || e.key === "q") history = null;
175191 else if (e.key === "m" && history.more) showHistory(true);
@@ -180,7 +196,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
180196 else if (e.key === "r") {
181197 const at = selected();
182198 if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`);
183 } else if (e.key === "l") like();
199 } else if (e.key === "enter" && items[state.index]?.opens === true) members = items[state.index]!;
200 else if (e.key === "l") like();
184201 else if (e.key === "h") showHistory();
185202 else if (e.key === "t") {
186203 timeline = !timeline;
src/web/studio/feed.ts +7 −4
@@ -150,10 +150,13 @@ export function feedCard(
150150 ${settledBadge(item.settled)}
151151 <span class="spacer"></span>
152152 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
153 const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p>
154 ${item.lines === undefined
155 ? null
156 : html`<ul class="list">${item.lines.map((l) => row({ href: l.link, title: l.text }))}${item.more ? row({ title: `and ${item.more} more` }) : null}</ul>`}
153 const lineList = item.lines === undefined
154 ? null
155 : html`<ul class="list">${item.lines.map((l) => row({ href: l.link, title: l.text }))}${item.more ? row({ title: `and ${item.more} more` }) : null}</ul>`;
156 const body = html`${item.opens === true
157 ? html`<details class="feed-fold"><summary class="break">${item.what}</summary>${lineList}</details>`
158 : html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p>`}
159 ${item.opens === true ? null : lineList}
157160 ${picture === undefined
158161 ? null
159162 : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true"
outdated

Duplicates lineList above; reuse it (principle: nothing says itself twice).

src/web/studio/feed_style.ts +2
@@ -13,6 +13,8 @@ export const feedCss = /* css */ `
1313 .feed .like { display: inline-block; margin: 0; }
1414 .feed .like [data-likes] { display: inline-block; min-width: 3ch; text-align: end; font-variant-numeric: tabular-nums; }
1515 .feed .card-foot > details { flex: 1 1 100%; }
16 /* A folded crew burst: the summary is the line; opening it grows the card in place. */
17 .feed-fold > summary { cursor: pointer; min-height: 44px; padding-block: 12px; display: flex; align-items: center; }
outdated

24px summary is tight for a thumb at 360px; add block padding.

1618 /* Your apps: a narrow column on a desk, a tab away on a phone. */
1719 /* An admin's tick boxes are always shown (no Select toggle); 24 px to tap. */
1820 #live-apps input[type="checkbox"] { width: 24px; height: 24px; }
test/feed-fold-bursts.test.ts added +77
@@ -0,0 +1,77 @@
1 /** A crew member's burst of runs and pulls folds into one line that opens; people and asks
2 * never fold. */
3 import { describe, expect, test } from "bun:test";
4 import { foldBursts } from "../src/feed/fold.ts";
5 import type { Candidate } from "../src/feed/items.ts";
6 import { feedCard } from "../src/web/studio/feed.ts";
7 import type { FeedItem } from "../src/feed/rank.ts";
8
9 const T0 = Date.parse("2026-11-14T10:00:00Z");
10 const make = (n: number, o: Partial<Candidate> = {}): Candidate => ({
11 id: `event:e${String(n).padStart(2, "0")}`,
12 kind: "run",
13 repoId: "r1",
14 app: "a/b",
15 actorId: "scout",
16 actor: "scout",
17 at: new Date(T0 + n * 60_000).toISOString(),
18 link: `/a/b/runs/${n}`,
19 what: `run ${n}`,
20 urgency: 0,
21 ...o,
22 });
23 const crew = new Set(["scout"]);
24
25 describe("folding a crew burst", () => {
26 const items: Candidate[] = [
27 make(0), make(1), make(2, { kind: "pull", link: "/a/b/pulls/2" }), make(3), make(4, { kind: "pull", link: "/a/b/pulls/4" }),
28 make(5, { actorId: "ann", actor: "ann" }),
29 make(6, { actorId: "ann", actor: "ann" }),
30 make(7, { actorId: "ann", actor: "ann" }),
31 make(8, { urgency: 4, what: "run 8 failed" }),
32 make(9, { kind: "ask", ask: { decision: "d", options: [], ifNothing: "", rank: 1, why: "" } }),
33 make(10, { actorId: "mason", actor: "mason" }),
34 make(11),
35 ];
36
37 test("one line with counts, members at their permalinks, people and asks untouched", () => {
38 const out = foldBursts(items, crew);
39 const fold = out.find((c) => c.opens === true)!;
40 expect(fold.what).toBe("scout: 4 runs, 2 pulls");
41 expect(fold.lines!.length).toBe(6);
42 expect(fold.lines!.map((l) => l.link)).toContain("/a/b/pulls/2");
43 // newest first
44 expect(fold.lines![0]!.link).toBe("/a/b/runs/11");
45 // 12 in, 6 folded to 1: 7 out
46 expect(out.length).toBe(7);
47 // a person's three posts, a failure, an ask and a non-crew actor all stay
48 const ids = out.map((c) => c.id);
49 for (const n of [5, 6, 7, 8, 9, 10]) expect(ids).toContain(`event:e${String(n).padStart(2, "0")}`);
50 // order of the rest is kept
51 expect(ids.filter((i) => !i.startsWith("fold:"))).toEqual(
52 ["05", "06", "07", "08", "09", "10"].map((n) => `event:e${n}`),
53 );
54 });
55
56 test("two are not a burst, and an hour apart is two bursts", () => {
57 expect(foldBursts([make(0), make(1)], crew).length).toBe(2);
58 const far = [make(0), make(1), make(2), make(120), make(121), make(122)];
59 expect(foldBursts(far, crew).filter((c) => c.opens).length).toBe(2);
60 });
61
62 test("with no crew nothing folds", () => {
63 expect(foldBursts([make(0), make(1), make(2)]).length).toBe(3);
64 });
65
66 test("the card is a details, server-rendered, with each member linked", () => {
67 const fold = foldBursts([make(0), make(1), make(2)], crew)[0]!;
68 const item: FeedItem = {
69 id: fold.id, kind: fold.kind, app: fold.app, actor: fold.actor, at: fold.at, link: fold.link,
70 what: fold.what, reason: "", score: 1, lines: fold.lines!, more: fold.more!, opens: true,
71 };
72 const out = String(feedCard(item));
73 expect(out).toContain('<details class="feed-fold"><summary class="break">scout: 3 runs</summary>');
74 expect(out).toContain('href="/a/b/runs/0"');
75 expect(out).not.toContain("<script");
76 });
77 });
test/told-by.test.ts +5 −2
@@ -141,9 +141,12 @@ describe("who told and who shipped", () => {
141141 test("the feed item says who told", async () => {
142142 await answered(repo, "feed");
143143 const items = readFeed(app.db, alice, { limit: 100 }).items;
144 // Scout's earlier pulls make a burst, which folds. The told-by words stay in each member's
145 // line on purpose (a burst has many tellers, so the fold's own line cannot name one).
146 const folds = items.filter((i) => i.opens === true);
147 expect(folds.length).toBeGreaterThan(0);
144148 expect(
145 items.some((i) => i.reason.includes("Notes, feed") &&
146 i.reason.includes("(told by bob, by scout)")),
149 folds.some((f) => (f.lines ?? []).some((l) => l.text.includes("(told by bob, by scout)"))),
147150 ).toBe(true);
148151 });
149152
test/tui-feed-fold.test.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The feed in the terminal folds a crew member's burst the way the page does: one line with
3 * counts, and `enter` on it lists the members, `esc` closes the list.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { addToCrew, createAgent } from "../src/actions/crew.ts";
10 import { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { promote } from "./helpers/members.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir = "";
23 let forge: App;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-tui-feed-fold-"));
27 forge = createApp({ dataDir: dir, port: 0 });
28 const made = await createUser(forge.db, {
29 origin: "seed", trusted: true, allowReserved: true, username: "alice", email: "[email protected]",
30 password: "a-good-passphrase", adminIfFirst: true,
31 });
32 if (!made.ok) throw new Error(made.error);
33 promote(forge.db, made.value);
34 const alice = getUserByName(forge.db, "alice")!;
35 const repo = await createRepo(forge, alice, { name: "open", visibility: "public" });
36 if (!repo.ok) throw new Error(String(repo.error));
37 await commitToBranch(forge, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# o\n" }] });
38 const agent = await createAgent(forge, alice, { username: "scout" });
39 if (!agent.ok) throw new Error(String(agent.error));
40 addToCrew(forge, alice, repo.value, "scout");
41 const scout = getUserByName(forge.db, "scout")!;
42 for (const n of [1, 2, 3]) {
43 await commitToBranch(forge, alice, repo.value, {
44 branch: `idea${n}`, base: "main", message: `idea ${n}`, files: [{ path: `i${n}.md`, content: `${n}\n` }],
45 });
46 const pull = await openPull(forge, scout, repo.value, {
47 title: `Idea ${n}`, body: "", baseBranch: "main", headBranch: `idea${n}`,
48 });
49 if (!pull.ok) throw new Error(String(pull.error));
50 }
51 });
52
53 afterAll(async () => {
54 forge.db.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 test("enter on the folded line lists its members; esc closes the list", async () => {
59 const terminal = headlessTerminal(140, 32);
60 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! };
61 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
62 app.start();
63 const press = async (...keys: string[]) => {
64 for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
65 await settleApp(app);
66 };
67 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
68 await settleApp(app);
69 await press(..."Feed", "enter");
70 expect(app.screen.toText()).toContain("scout: 3 pulls");
71 expect(app.screen.toText()).not.toContain("Idea 2");
72 await press("enter");
73 const opened = app.screen.toText();
74 for (const n of [1, 2, 3]) expect(opened).toContain(`Idea ${n}`);
75 await press("esc");
76 expect(app.screen.toText()).toContain("scout: 3 pulls");
77 expect(app.screen.toText()).not.toContain("Idea 2");
78 app.stop();
79 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs