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

A client's app row says what was last finished on it, in plain words #6093

Merged opened this

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

Merged

scout/forge:scout/6091-a-client-s-app-row-says-what-was-last-fi → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/6091-a-client-s-app-row-says-what-was-last-fi was merged into main as daff69a.

Review

Sign in to review this change.
7 files changed +189 −92
README.md +4 −3
@@ -350,9 +350,10 @@ link), the home is their apps, one plain sentence each and one more saying
350350 whether its agents are working, about to start, or waiting: no map, board or
351351 settings until asked. `/?all=1` is the whole home, and its "Just my apps" link
352352 comes back. Each app row also says what its agents are on ("Working on: …")
353 or the last change landed in the past 7 days ("Last change: …, 3 days ago");
354 a quiet app says nothing more. The terminal's first screen opens with the same
355 lines, from the same queries (`src/core/client-apps.ts`).
353 and what was last finished on it ("Finished 3 days ago: …", its newest merged
354 pull however old, read for all rows in one query); an app with neither says
355 nothing more. Both doors show both lines: the terminal's first screen opens
356 with the same lines, worded by the same helpers (`src/core/client-apps.ts`).
356357
357358 **Reading the home as the eye does (#5735, step 1).** `bun scripts/home-read.ts <url>` fetches a page and prints its visible text, then one line: `chars N · words N · fetched in N ms`. Set `FORGE_COOKIE` (for example `forge_session=…; forge_mode=studio`) to read it as the owner; the cookie is sent and never printed. It exits non-zero on any status but 200. The conversion is `pageText` in `src/web/page_text.ts`: scripts, styles, templates, the head and anything `hidden` or `aria-hidden="true"` are dropped, link text stays, blocks sit on their own lines, entities are decoded. The method then continues: (2) a fresh agent with no context reads only that text and answers in at most 150 words, (3) the steward scores it against ground truth on coverage of the 5 things that mattered, errors, and seconds, (4) repeat after every feed change. The steward runs steps 2–4 and logs them on #5735.
358359
docs/design/areas/tell.md +7
@@ -6,3 +6,10 @@ shifts and Enter sends it with the page's path as `from`. Any failure falls back
66
77 - `.tell-here` — the fixed box holding that composer: pinned to the bottom of the viewport, at most 620px wide and
88 centred, above the page; the `.comment-form` inside drops its margin and takes `--shadow-2` so it reads as raised.
9
10 ## Client home: `client-app-finished`
11
12 A muted line on a client's app row, `Finished <time>: <title>`, for the app's
13 newest merged pull. It sits with `client-app-about`, `client-app-agents` and
14 `client-app-doing`; it uses `muted` and adds no colour or size. The
15 terminal says the same words.
src/core/client-apps.ts +62 −51
@@ -54,60 +54,70 @@ export async function clientAppRows(
5454 });
5555 }
5656
57 /** What one app's agents are doing, in words a client can read. */
58 export type ClientDoing =
59 | { readonly kind: "working"; readonly title: string }
60 | { readonly kind: "landed"; readonly title: string; readonly at: string };
61
62 const WEEK_MS = 7 * 24 * 3600 * 1000;
57 /** What one app's agents are doing now, in words a client can read. */
58 export interface ClientDoing {
59 readonly title: string;
60 }
6361
6462 /**
65 * For each of `appIds` (repo ids), the title of the work running now, else of
66 * the last change landed in the 7 days before `now`; an app with neither is
67 * left out. One query, at most one row per app.
63 * For each of `appIds` (repo ids), the title of the work running now; an app
64 * with none is left out. What landed is `clientFinished`'s. One query, at most
65 * one row per app.
6866 */
6967 export function clientDoings(
7068 db: Database,
7169 appIds: readonly string[],
72 now: Date,
7370 ): ReadonlyMap<string, ClientDoing> {
7471 const doings = new Map<string, ClientDoing>();
7572 if (appIds.length === 0) return doings;
76 const marks = appIds.map((_, i) => `?${i + 3}`).join(",");
7773 const rows = db
78 .query<
79 { repo_id: string; kind: string; title: string; at: string },
80 (string | number)[]
81 >(
82 `SELECT repo_id, kind, title, at FROM (
83 SELECT repo_id, kind, title, at,
84 ROW_NUMBER() OVER (PARTITION BY repo_id ORDER BY rank, at DESC) AS n
85 FROM (
86 SELECT r.repo_id AS repo_id, 'working' AS kind, i.title AS title,
87 COALESCE(r.started_at, r.created_at) AS at, 0 AS rank
88 FROM run r JOIN issue i ON i.id = r.issue_id
89 WHERE r.kind = 'agent' AND r.state = 'running'
90 AND r.repo_id IN (${marks})
91 UNION ALL
92 SELECT i.repo_id, 'landed', i.title, p.merged_at, 1
93 FROM issue i JOIN pull p ON p.issue_id = i.id
94 WHERE i.kind = 'pull' AND i.state = 'closed'
95 AND i.repo_id IN (${marks}) AND p.merged_at >= ?1
96 )
97 ) WHERE n = 1 LIMIT ?2`,
74 .query<{ repo_id: string; title: string }, string[]>(
75 `SELECT repo_id, title FROM (
76 SELECT r.repo_id AS repo_id, i.title AS title,
77 ROW_NUMBER() OVER (
78 PARTITION BY r.repo_id ORDER BY COALESCE(r.started_at, r.created_at) DESC
79 ) AS n
80 FROM run r JOIN issue i ON i.id = r.issue_id
81 WHERE r.kind = 'agent' AND r.state = 'running'
82 AND r.repo_id IN (${appIds.map((_, i) => `?${i + 1}`).join(",")})
83 ) WHERE n = 1`,
9884 )
99 .all(new Date(now.getTime() - WEEK_MS).toISOString(), appIds.length, ...appIds);
100 for (const row of rows) {
101 doings.set(
102 row.repo_id,
103 row.kind === "working"
104 ? { kind: "working", title: row.title }
105 : { kind: "landed", title: row.title, at: row.at },
106 );
107 }
85 .all(...appIds);
86 for (const row of rows) doings.set(row.repo_id, { title: row.title });
10887 return doings;
10988 }
11089
90 /** What one app last had finished: the title of its newest merged pull and when. */
91 export interface ClientFinished {
92 readonly title: string;
93 readonly at: string;
94 }
95
96 /**
97 * For each of `appIds` (repo ids), its most recently merged pull, however old;
98 * an app with none is left out. One query, at most one row per app.
99 */
100 export function clientFinished(
101 db: Database,
102 appIds: readonly string[],
103 ): ReadonlyMap<string, ClientFinished> {
104 const finished = new Map<string, ClientFinished>();
105 if (appIds.length === 0) return finished;
106 const rows = db
107 .query<{ repo_id: string; title: string; at: string }, string[]>(
108 `SELECT repo_id, title, at FROM (
109 SELECT i.repo_id AS repo_id, i.title AS title, p.merged_at AS at,
110 ROW_NUMBER() OVER (PARTITION BY i.repo_id ORDER BY p.merged_at DESC) AS n
111 FROM issue i JOIN pull p ON p.issue_id = i.id
112 WHERE i.kind = 'pull' AND p.merged_at IS NOT NULL
113 AND i.repo_id IN (${appIds.map((_, i) => `?${i + 1}`).join(",")})
114 ) WHERE n = 1`,
115 )
116 .all(...appIds);
117 for (const row of rows) finished.set(row.repo_id, { title: row.title, at: row.at });
118 return finished;
119 }
120
111121 const TITLE_MAX = 80;
112122
113123 /** `title` cut to 80 characters at a word boundary, with "…" when anything was dropped. */
@@ -119,15 +129,16 @@ export function cutTitle(title: string): string {
119129 return `${(space > 0 ? head.slice(0, space) : head).trimEnd()}…`;
120130 }
121131
outdated

Context: the recent-merge "Last change" wording lives here. It should yield to the new Finished line so one merge reads once.

122 /** The sentence about what an app's agents are on, or "" for a quiet app. */
123 export function doingLine(doing: ClientDoing | undefined, now: number): string {
124 if (!doing) return "";
125 const title = cutTitle(doing.title);
126 if (title === "") return "";
127 switch (doing.kind) {
128 case "working":
129 return `Working on: ${title}`;
130 case "landed":
131 return `Last change: ${title}, ${ago(Date.parse(doing.at), now)}`;
132 }
132 /** The sentence about what an app's agents are on, or "" when none is running. */
133 export function doingLine(doing: ClientDoing | undefined): string {
134 const title = doing === undefined ? "" : cutTitle(doing.title);
135 return title === "" ? "" : `Working on: ${title}`;
136 }
137
138 /** `Finished 3 days ago: Fix the menu`, or "" for an app with nothing finished; both doors word it so. */
139 export function finishedLine(finished: ClientFinished | undefined, now: number): string {
140 const title = finished === undefined ? "" : cutTitle(finished.title);
141 if (finished === undefined || title === "") return "";
142 const at = Date.parse(finished.at);
143 return Number.isNaN(at) ? "" : `Finished ${ago(at, now)}: ${title}`;

The finished === undefined check is redundant with the title computation above it. Fold the two into one early return.

133144 }
src/tui/screens/dashboard.ts +10 −3
@@ -20,7 +20,7 @@ import { forgeLane, forgeNeeds } from "../../actions/health.ts";
2020 import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts";
2121 import { forgeHealth } from "../../kernel/guard.ts";
2222 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
23 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
23 import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts";
2424 import { nextStepsFor } from "../../core/next-steps.ts";
2525 import { agentsLine } from "../../web/studio/client_home.ts";
2626 import { askForSomething } from "./tell.ts";
@@ -200,12 +200,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
200200 clientRepoIds = new Set(apps.map((a) => a.repo.id));
201201 clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const])));
202202 const now = new Date();
203 const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now);
203 const ids = apps.map((a) => a.repo.id);
204 const doings = clientDoings(f.app.db, ids);
205 const finished = clientFinished(f.app.db, ids);
204206 clientLines =
205207 apps.length === 0
206208 ? ["Your apps will appear here."]
207209 : apps.map((a) => {
208 const doing = doingLine(doings.get(a.repo.id), now.getTime());
210 const doing = [
211 finishedLine(finished.get(a.repo.id), now.getTime()),
212 doingLine(doings.get(a.repo.id)),
213 ]
214 .filter((x) => x !== "")
215 .join(" ");
209216 const mark = a.live === undefined ? "" : " (running, o opens it)";
210217 const line = `${a.repo.name}${mark}: ${agentsLine(a.working, a.queued)}`;
211218 return doing === "" ? line : `${line} ${doing}`;
src/web/studio/client_home.ts +8 −3
@@ -7,7 +7,7 @@
77 */
88
99 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
10 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
10 import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts";
1111 import type { User } from "../../core/users.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { mayDirectCrew } from "../../crew/direct.ts";
@@ -31,11 +31,15 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
3131 const apps = await timePhaseAsync(ctx.request, "apps", () => clientAppRows(ctx.app, user));
3232 const now = new Date();
3333 const doings = timePhase(ctx.request, "doing", () =>
34 clientDoings(ctx.app.db, apps.map((a) => a.repo.id), now),
34 clientDoings(ctx.app.db, apps.map((a) => a.repo.id)),
35 );
36 const finished = timePhase(ctx.request, "finished", () =>
37 clientFinished(ctx.app.db, apps.map((a) => a.repo.id)),
3538 );
3639 const rendering = performance.now();
3740 const rows = apps.map(({ repo, working, queued, live }) => {
38 const doing = doingLine(doings.get(repo.id), now.getTime());
41 const doing = doingLine(doings.get(repo.id));
42 const done = finishedLine(finished.get(repo.id), now.getTime());
3943 const about = repo.description !== "" ? repo.description : "An app of yours.";
4044 // The tell form with this app chosen; it sends the person back to `/`.
4145 const ask = mayDirectCrew(ctx.app.db, repo, user)
@@ -57,6 +61,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
5761 <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}"
5862 >${agentsLine(working, queued)}</span
5963 >
64 ${done !== "" ? html`<span class="client-app-finished muted">${done}</span>` : ""}
6065 ${doing !== "" ? html`<span class="client-app-doing muted">${doing}</span>` : ""}
outdated

Duplicates the "Last change" line below for merges in the last 7 days. Fold them into one line. Also done! can be avoided by narrowing.

6166 </li>`;
6267 });
test/client-apps.test.ts +39 −29
@@ -1,7 +1,7 @@
1 /** What a client's apps are doing: the work running, else the last change landed this week (#4007). */
1 /** What a client's apps are doing: the work running, else what was last finished (#4007, #6091). */
22 import { beforeEach, describe, expect, test } from "bun:test";
33 import type { Database } from "bun:sqlite";
4 import { clientDoings } from "../src/core/client-apps.ts";
4 import { clientDoings, clientFinished, doingLine, finishedLine } from "../src/core/client-apps.ts";
55 import { migrations } from "../src/core/schema.ts";
66 import { openDb } from "../src/kernel/db.ts";
77
@@ -33,41 +33,51 @@ function running(repo: string, issueId: string | null): void {
3333 describe("clientDoings", () => {
3434 test("a running run names its issue", () => {
3535 running("a", issue("a", "Add a contact form"));
36 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Add a contact form" });
36 expect(clientDoings(db, ["a"]).get("a")).toEqual({ title: "Add a contact form" });
3737 });
3838
39 test("a change landed inside the week is reported with when", () => {
40 landed("a", "Fix the footer", "2026-10-15T13:00:00.000Z");
41 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "landed", title: "Fix the footer", at: "2026-10-15T13:00:00.000Z" });
42 });
43
44 test("a change landed more than 7 days ago is ignored", () => {
45 landed("a", "Old", "2026-10-14T11:59:59.000Z");
46 expect(clientDoings(db, ["a"], NOW).size).toBe(0);
47 });
48
49 test("the latest landed change wins", () => {
50 landed("a", "First", "2026-10-16T10:00:00.000Z");
51 landed("a", "Second", "2026-10-18T10:00:00.000Z");
52 expect(clientDoings(db, ["a"], NOW).get("a")).toMatchObject({ title: "Second" });
53 });
54
55 test("working beats landed", () => {
56 landed("a", "Done", "2026-10-20T10:00:00.000Z");
57 running("a", issue("a", "Doing"));
58 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Doing" });
39 test("a change that landed is not work in progress", () => {
40 landed("a", "Fix the footer", "2026-10-20T10:00:00.000Z");
41 expect(clientDoings(db, ["a"]).size).toBe(0);
5942 });
6043
6144 test("a running run with no issue yields nothing", () => {
6245 running("a", null);
63 expect(clientDoings(db, ["a"], NOW).size).toBe(0);
46 expect(clientDoings(db, ["a"]).size).toBe(0);
6447 });
6548
6649 test("a quiet app, an unlisted app and no apps yield nothing; apps are kept apart", () => {
67 landed("b", "Mine", "2026-10-20T10:00:00.000Z");
68 landed("c", "Not asked", "2026-10-20T10:00:00.000Z");
69 const got = clientDoings(db, ["a", "b"], NOW);
70 expect([...got.keys()]).toEqual(["b"]);
71 expect(clientDoings(db, [], NOW).size).toBe(0);
50 running("b", issue("b", "Mine"));
51 running("c", issue("c", "Not asked"));
52 expect([...clientDoings(db, ["a", "b"]).keys()]).toEqual(["b"]);
53 expect(clientDoings(db, []).size).toBe(0);
54 });
55 });
56
57 describe("clientFinished", () => {
58 test("the newest merged pull wins, however old", () => {
59 landed("a", "First", "2025-01-16T10:00:00.000Z");
60 landed("a", "Second", "2025-02-18T10:00:00.000Z");
61 expect(clientFinished(db, ["a"]).get("a")).toEqual({ title: "Second", at: "2025-02-18T10:00:00.000Z" });
62 });
63
64 test("a quiet app, an unlisted app and no apps yield nothing", () => {
65 landed("b", "Mine", "2026-10-20T10:00:00.000Z");
66 landed("c", "Not asked", "2026-10-20T10:00:00.000Z");
67 expect([...clientFinished(db, ["a", "b"]).keys()]).toEqual(["b"]);
68 expect(clientFinished(db, []).size).toBe(0);
69 });
70 });
71
72 describe("the lines", () => {
73 test("finishedLine spells the age out and is empty with nothing finished", () => {
74 const at = new Date(NOW.getTime() - 3 * 86_400_000).toISOString();
75 expect(finishedLine({ title: "Fix the menu", at }, NOW.getTime())).toBe("Finished 3 days ago: Fix the menu");
76 expect(finishedLine(undefined, NOW.getTime())).toBe("");
77 });
78
79 test("doingLine says Working on, or nothing", () => {
80 expect(doingLine({ title: "Add rye" })).toBe("Working on: Add rye");
81 expect(doingLine(undefined)).toBe("");
7282 });
7383 });
test/client-home.test.ts +59 −3
@@ -506,13 +506,48 @@ describe("a client's first screen", () => {
506506 clean();
507507 });
508508
509 test("a change landed this week reads Last change with its age", async () => {
509 test("a change landed this week reads Finished once, not Last change too", async () => {
510510 clean();
511511 seedIssue("i-doing-2", 9002, "pull", "Fix the menu", "closed");
512512 child.db
513513 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('i-doing-2', 'main', 'x', ?1, ?1, ?2)")
514514 .run("0".repeat(40), new Date(Date.now() - 3 * 86_400_000).toISOString());
515 expect(await row()).toContain("Last change: Fix the menu, 3 days ago");
515 const r = await row();
516 expect(r).toContain("Finished");
517 expect(r).toContain("3 days ago");
518 expect(r).toContain("Fix the menu");
519 expect(r).not.toContain("Last change");
520 expect(r).not.toContain("client-app-doing");
521 clean();
522 });
523
524 test("the newest merged pull reads Finished, on that app only, escaped, with its own phase", async () => {
525 clean();
526 const maria = ids().maria;
527 const other = await createRepo(child, maria, { name: "cafe2", owner: "marias" });
528 if (!other.ok) throw new Error(JSON.stringify(other.error));
529 const none = main(await page(owner));
530 expect(none).not.toContain("client-app-finished");
531 seedIssue("i-doing-4", 9004, "pull", "Older <i>fix</i>", "closed");
532 seedIssue("i-doing-5", 9005, "pull", "Add <b>rye</b> bread", "closed");
533 const ins = child.db.query(
534 "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'x', ?2, ?2, ?3)",
535 );
536 ins.run("i-doing-4", "0".repeat(40), new Date(Date.now() - 90 * 86_400_000).toISOString());
537 ins.run("i-doing-5", "0".repeat(40), new Date(Date.now() - 40 * 86_400_000).toISOString());
538 const home = main(await page(owner));
539 const line = home.match(/client-app-finished[^>]*>([^]*?)<\/span>/)?.[1] ?? "";
540 expect(line).toContain("Finished");
541 expect(line).toContain("Add &lt;b&gt;rye&lt;/b&gt; bread");
542 expect(line).not.toContain("Older");
543 for (const word of ["pull", "branch", "commit", "merge", "CI", "repository", "repositories"])
544 expect(words(line)).not.toMatch(new RegExp(`\\b${word}`, "i"));
545 expect(home.match(/client-app-finished/g)?.length).toBe(1);
546 const cafe = home.slice(home.indexOf('href="/marias/cafe2"'));
547 expect(cafe.slice(0, cafe.indexOf("</li>"))).not.toContain("Finished");
548 const res = await fetch(url("/"), { headers: { cookie: owner } });
549 expect(res.headers.get("server-timing") ?? "").toMatch(/\bfinished;/);
550 child.db.query("DELETE FROM repo WHERE id = ?1").run(other.value.id);
516551 clean();
517552 });
518553
@@ -542,6 +577,27 @@ describe("a client's first screen", () => {
542577 clean();
543578 });
544579
580 test("the terminal's line for the app says what was finished too", async () => {
581 clean();
582 seedIssue("i-doing-6", 9006, "pull", "Fix the menu", "closed");
583 child.db
584 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('i-doing-6', 'main', 'x', ?1, ?1, ?2)")
585 .run("0".repeat(40), new Date(Date.now() - 3 * 86_400_000).toISOString());
586 const terminal = headlessTerminal(160, 30);
587 const { app } = createForgeApp(
588 child,
589 { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: ids().maria },
590 { terminal, exitHooks: false },
591 );
592 app.start();
593 await settleApp(app);
594 const text = app.screen.toText();
595 expect(text).toContain("Finished 3 days ago: Fix the menu");
596 expect(text).not.toContain("Last change");
597 app.stop();
598 clean();
599 });
600
545601 test("titles are cut to 80 characters at a word boundary", () => {
546602 const long = "word ".repeat(30);
547603 const cut = cutTitle(long);
@@ -549,7 +605,7 @@ describe("a client's first screen", () => {
549605 expect(cut.endsWith("word…")).toBe(true);
550606 expect(cutTitle("short title")).toBe("short title");
551607 expect(cutTitle("x".repeat(200))).toBe(`${"x".repeat(79)}…`);
552 expect(doingLine(undefined, 0)).toBe("");
608 expect(doingLine(undefined)).toBe("");
553609 });
554610 });
555611 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs