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

The signed-in home timeline says what to do when it is empty, in one line and one button #10768

Merged opened this

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

Merged

mason/forge:mason/10764-the-signed-in-home-timeline-says-what-to → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/10764-the-signed-in-home-timeline-says-what-to was merged into main as 2a28711.

Review

Sign in to review this change.
6 files changed +120 −8
docs/MCP.md +2 −2
@@ -79,7 +79,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7979 | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 |
8080 | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 |
8181 | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 313 |
82 | `crew` | — | List a repository's crew, or add or remove an agent | 338 |
8383 | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 |
8484 | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 |
8585 | `detach` | — | Detach a fork from its parent | 210 |
@@ -89,7 +89,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
8989 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9090 | `email_verify` | — | Send yourself a new verification link | 126 |
9191 | `events` | — | Repository events | 259 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 |
92 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 |
9393 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
9494 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
9595 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
docs/features/home-frame.md +4
@@ -43,3 +43,7 @@ Tests: `test/home-frame.test.ts` (thousands of issues; p95 of the server's own
4343 time after a write under 50 ms; first response under 100 KB uncompressed),
4444 `test/home-memory.test.ts`. Tests that write and then read home use
4545 `test/helpers/home-fresh.ts`, which does what the page's script does.
46
47 ## An empty timeline
48
49 A signed-in person whose timeline has nothing in it sees one line under the tell box, the named `empty` component with a title only: "Nothing yet. Tell the crew what to build." (`EMPTY_FEED`, `src/web/studio/feed.ts`). The tell box is already first on the page, so no field steals focus (a phone keyboard would cover the sentence). The terminal's Feed screen draws the same constant.
src/tui/screens/feed.ts +2 −1
@@ -27,6 +27,7 @@ import { truncate } from "../term/width.ts";
2727 import { fill, len, split } from "../ui/layout.ts";
2828 import { stepLine } from "../../flow/path.ts";
2929 import { emptyState } from "../ui/widgets.ts";
30 import { EMPTY_FEED } from "../../web/studio/feed.ts";

Non-blocking: the terminal now imports a constant from the web studio module, which pulls the page code into the TUI graph. A tiny shared copy module would keep the two doors independent.

3031 import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts";
3132 import type { PaletteEntry } from "./entries.ts";
3233 import { Composer } from "./thread.ts";
@@ -140,7 +141,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
140141 render(area, s) {
141142 const c = ctx.theme.c;
142143 if (items.length === 0) {
143 emptyState(s, area, c, { title: "Nothing in the feed yet", body: "Posts about the apps you can see land here." });
144 emptyState(s, area, c, { title: EMPTY_FEED });
144145 return;
145146 }
146147 if (history !== null) {
src/web/studio/feed.ts +4 −5
@@ -362,6 +362,9 @@ function readRegion(user: User, ctx: Ctx) {
362362 return { items, next: page.next_cursor, cursor, timeline, asked, lastDayOnPage, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
363363 }
364364
365 /** The one line an empty timeline says; the tell box above it is the action. */
366 export const EMPTY_FEED = "Nothing yet. Tell the crew what to build.";
367
365368 /** The region: cards, or one line saying there is nothing to do. */
366369 export function feedRegion(user: User, ctx: Ctx): Html {
367370 return drawRegion(user, ctx, readRegion(user, ctx));
@@ -426,11 +429,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
426429 ${chipsBar(user, ctx, items)}
427430 ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))}
428431 ${items.length === 0 && thoughts.length === 0
429 ? empty({
430 icon: "activity",
431 title: "Nothing new",
432 note: "When something happens in your apps, it lands here.",
433 })
432 ? empty({ title: EMPTY_FEED })
outdated

Principle 1/7: hand-rolled .empty markup in place of the named empty() component. Call empty({title: EMPTY_FEED}) (or extend it to allow no icon/note) and expose an empty flag from the region instead of the data-empty-feed marker.

434433 : posts}
435434 ${quiet === 0
436435 ? ""
test/feed-empty-prompt.test.ts added +58
@@ -0,0 +1,58 @@
1 /** A signed-in person with an empty timeline sees one sentence and a tell box ready to type in, in the first paint. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let cookie: string;
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-empty-"));
20 app = createApp({ dataDir, port: 0 });
21 servers = await serve(app);
22 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "newbie", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
23 if (!r.ok) throw new Error(r.error);
24 cookie = `forge_session=${createSession(app.db, r.value.id).token}`;
25 });
26
27 afterAll(async () => {
28 servers.stop();
29 app.close();
30 await rm(dataDir, { recursive: true, force: true });
31 });
32
33 test("an empty home says one sentence below the tell box, and the old line is gone", async () => {
34 const res = await fetch(`${servers.url}/feed`, { headers: { cookie } });
35 const page = await res.text();
36 expect(res.status).toBe(200);
37 const sentence = page.indexOf("Nothing yet. Tell the crew what to build.");
38 expect(sentence).toBeGreaterThan(-1);
outdated

Add the opposite case: a feed with a post has no autofocus and no empty sentence. As written this test passes if autofocus were always on.

39 const form = page.match(/<form id="composer"[\s\S]*?<\/form>/)!;
40 expect(form[0]).toContain('action="/tell"');
41 expect(form[0]).not.toContain("Nothing yet");
42 expect(sentence).toBeGreaterThan(page.indexOf(form[0]) + form[0].length);
43 expect(page).not.toContain("Nothing new");
44 expect(page).not.toContain("autofocus");
45 });
46
47 test("a feed with something in it keeps its cards: no sentence, no autofocus", async () => {
48 const user = getUserByName(app.db, "newbie")!;
49 const made = await createRepo(app, user, { name: "game" });
50 if (!made.ok) throw new Error(String(made.error));
51 await commitToBranch(app, user, made.value, { message: "seed", files: [{ path: "README.md", content: "# game\n" }] });
52 const opened = openIssue(app, user, made.value, { title: "make the jump higher" });
53 if (!opened.ok) throw new Error(String(opened.error));
54 const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text();
55 expect(page).toContain("make the jump higher");
56 expect(page).not.toContain("Nothing yet. Tell the crew what to build.");
57 expect(page).not.toContain("autofocus");
58 });
test/tui-feed-empty.test.ts added +50
@@ -0,0 +1,50 @@
1 /** The feed in the terminal says the same one line as the page when there is nothing in it. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser, getUserByName } from "../src/core/users.ts";
8 import { createForgeApp } from "../src/tui/forge.ts";
9 import { headlessTerminal } from "../src/tui/term/terminal.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11 import { promote } from "./helpers/members.ts";
12 import { settleApp } from "./helpers/tui.ts";
13
14 quietLogs();
15
16 let dir = "";
17 let forge: App;
18
19 beforeAll(async () => {
20 dir = await mkdtemp(join(tmpdir(), "forge-tui-feed-empty-"));
21 forge = createApp({ dataDir: dir, port: 0 });
22 const made = await createUser(forge.db, {
23 origin: "seed", trusted: true, allowReserved: true, username: "root", email: "[email protected]",
24 password: "a-good-passphrase", adminIfFirst: true,
25 });
26 if (!made.ok) throw new Error(made.error);
27 promote(forge.db, made.value);
28 });
29
30 afterAll(async () => {
31 forge.db.close();
32 await rm(dir, { recursive: true, force: true });
33 });
34
35 test("an empty feed says the page's line", async () => {
36 const terminal = headlessTerminal(140, 32);
37 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "root")! };
38 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
39 app.start();
40 const key = async (k: string, ctrl = false) => {
41 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
42 await settleApp(app);
43 };
44 await key("k", true);
45 for (const k of [..."Feed", "enter"]) await key(k);
46 const text = app.screen.toText();
47 expect(text).toContain("Nothing yet. Tell the crew what to build.");
48 expect(text).not.toContain("Nothing in the feed yet");
49 app.stop();
50 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs