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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10764-the-signed-in-home-timeline-says-what-to → main 4 commits fork 4 threads · 4 unresolved
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`; | ||
| 79 | 79 | | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 | |
| 80 | 80 | | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 | |
| 81 | 81 | | `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 | | |
| 83 | 83 | | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 | |
| 84 | 84 | | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 | |
| 85 | 85 | | `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`; | ||
| 89 | 89 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 90 | 90 | | `email_verify` | — | Send yourself a new verification link | 126 | |
| 91 | 91 | | `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 | | |
| 93 | 93 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | |
| 94 | 94 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | |
| 95 | 95 | | `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 | ||
| 43 | 43 | time after a write under 50 ms; first response under 100 KB uncompressed), |
| 44 | 44 | `test/home-memory.test.ts`. Tests that write and then read home use |
| 45 | 45 | `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"; | ||
| 27 | 27 | import { fill, len, split } from "../ui/layout.ts"; |
| 28 | 28 | import { stepLine } from "../../flow/path.ts"; |
| 29 | 29 | import { emptyState } from "../ui/widgets.ts"; |
| 30 | import { EMPTY_FEED } from "../../web/studio/feed.ts"; | |
| 30 | 31 | import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts"; |
| 31 | 32 | import type { PaletteEntry } from "./entries.ts"; |
| 32 | 33 | import { Composer } from "./thread.ts"; |
| @@ -140,7 +141,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 140 | 141 | render(area, s) { |
| 141 | 142 | const c = ctx.theme.c; |
| 142 | 143 | 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 }); | |
| 144 | 145 | return; |
| 145 | 146 | } |
| 146 | 147 | if (history !== null) { |
src/web/studio/feed.ts +4 −5
| @@ -362,6 +362,9 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 362 | 362 | return { items, next: page.next_cursor, cursor, timeline, asked, lastDayOnPage, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink }; |
| 363 | 363 | } |
| 364 | 364 | |
| 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 | ||
| 365 | 368 | /** The region: cards, or one line saying there is nothing to do. */ |
| 366 | 369 | export function feedRegion(user: User, ctx: Ctx): Html { |
| 367 | 370 | return drawRegion(user, ctx, readRegion(user, ctx)); |
| @@ -426,11 +429,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 426 | 429 | ${chipsBar(user, ctx, items)} |
| 427 | 430 | ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))} |
| 428 | 431 | ${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. |
||
| 434 | 433 | : posts} |
| 435 | 434 | ${quiet === 0 |
| 436 | 435 | ? "" |
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 | }); |
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.