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

On a client's first screen each app row says "Working on: …" or "Last change: …, 3 days ago" #4056

Merged opened this

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

Merged

scout/forge:scout/4008-on-a-client-s-first-screen-each-app-row → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/4008-on-a-client-s-first-screen-each-app-row was merged into main as b101086.

Review

Sign in to review this change.
5 files changed +137 −9
README.md +4 −2
@@ -337,8 +337,10 @@ On a forge made for a client (one that was spawned, signed in by its claim
337337 link), the home is their apps, one plain sentence each and one more saying
338338 whether its agents are working, about to start, or waiting: no map, board or
339339 settings until asked. `/?all=1` is the whole home, and its "Just my apps" link
340 comes back. The terminal's first screen opens with the same lines, from the
341 same query (`src/core/client-apps.ts`).
340 comes back. Each app row also says what its agents are on ("Working on: …")
341 or the last change landed in the past 7 days ("Last change: …, 3 days ago");
342 a quiet app says nothing more. The terminal's first screen opens with the same
343 lines, from the same queries (`src/core/client-apps.ts`).
342344
343345 Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with
344346 a 24 h sparkline), the live version and since when, the median check time, the crew builders
src/core/client-apps.ts +25
@@ -6,6 +6,7 @@
66
77 import type { Database } from "bun:sqlite";
88 import type { App } from "../app.ts";
9 import { ago } from "../feed/rank.ts";
910 import { spawnedRepos } from "./next-steps.ts";
1011 import { withoutArchived, type Repo } from "./repos.ts";
1112 import type { User } from "./users.ts";
@@ -94,3 +95,27 @@ export function clientDoings(
9495 }
9596 return doings;
9697 }
98
99 const TITLE_MAX = 80;
100
101 /** `title` cut to 80 characters at a word boundary, with "…" when anything was dropped. */
102 export function cutTitle(title: string): string {
103 const text = title.replace(/\s+/g, " ").trim();
104 if (text.length <= TITLE_MAX) return text;
105 const head = text.slice(0, TITLE_MAX - 1);
106 const space = head.lastIndexOf(" ");
107 return `${(space > 0 ? head.slice(0, space) : head).trimEnd()}…`;
108 }
109
110 /** The sentence about what an app's agents are on, or "" for a quiet app. */
111 export function doingLine(doing: ClientDoing | undefined, now: number): string {
112 if (!doing) return "";
113 const title = cutTitle(doing.title);
114 if (title === "") return "";
115 switch (doing.kind) {
116 case "working":
117 return `Working on: ${title}`;
118 case "landed":
119 return `Last change: ${title}, ${ago(Date.parse(doing.at), now)}`;
120 }
121 }
src/tui/screens/dashboard.ts +8 −4
@@ -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 } from "../../core/client-apps.ts";
23 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
2424 import { nextStepsFor } from "../../core/next-steps.ts";
2525 import { agentsLine } from "../../web/studio/client_home.ts";
2626 import { mayView } from "../../core/sealed.ts";
@@ -186,12 +186,16 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
186186 // A client's home never carries the forge-wide figures, on either door.
187187 figures = [];
188188 const apps = await clientAppRows(f.app, f.user);
189 const now = new Date();
190 const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now);
189191 clientLines =
190192 apps.length === 0
191193 ? ["Your apps will appear here."]
192 : apps.map(
193 (a) => `${a.repo.name}: ${agentsLine(a.working, a.queued)}`,
194 );
194 : apps.map((a) => {
195 const doing = doingLine(doings.get(a.repo.id), now.getTime());
196 const line = `${a.repo.name}: ${agentsLine(a.working, a.queued)}`;
197 return doing === "" ? line : `${line} ${doing}`;

Joined onto the same line as the agents sentence, this can pass 100 columns; consider a second line per app.

198 });
195199 ctx.invalidate();
196200 };
197201
src/web/studio/client_home.ts +8 −2
@@ -6,8 +6,8 @@
66 * stay off it; `test/client-home.test.ts` holds the list.
77 */
88
9 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
10 import { clientAppRows } from "../../core/client-apps.ts";
9 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
10 import { clientAppRows, clientDoings, doingLine } from "../../core/client-apps.ts";
1111 import type { User } from "../../core/users.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { repoBase } from "../chrome.ts";
@@ -28,8 +28,13 @@ export function agentsLine(working: number, queued: number): string {
2828 export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
2929 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3030 const apps = await timePhaseAsync(ctx.request, "apps", () => clientAppRows(ctx.app, user));
31 const now = new Date();
32 const doings = timePhase(ctx.request, "doing", () =>
33 clientDoings(ctx.app.db, apps.map((a) => a.repo.id), now),
34 );
3135 const rendering = performance.now();
3236 const rows = apps.map(({ repo, working, queued }) => {
37 const doing = doingLine(doings.get(repo.id), now.getTime());
3338 const about = repo.description !== "" ? repo.description : "An app of yours.";
3439 return html`<li class="client-app">
3540 <a href="${repoBase(repo)}"><b>${repo.name}</b></a>
@@ -37,6 +42,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
3742 <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}"
3843 >${agentsLine(working, queued)}</span
3944 >
45 ${doing !== "" ? html`<span class="client-app-doing muted">${doing}</span>` : ""}

This span is inline beside the agents span; stack the row so the sentence starts on its own line on a narrow screen.

4046 </li>`;
4147 });
4248 const shown = card(
test/client-home.test.ts +92 −1
@@ -12,7 +12,7 @@ import { join } from "node:path";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1414 import { createApp, type App } from "../src/app.ts";
15 import { clientAppRows } from "../src/core/client-apps.ts";
15 import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts";
1616 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
1717 import { moveArchived } from "../src/core/repos.ts";
1818 import { createSession } from "../src/core/sessions.ts";
@@ -317,4 +317,95 @@ describe("a client's first screen", () => {
317317 expect(await names()).toEqual(["bakery", "cafe"]);
318318 expect(await apps()).toContain('href="/marias/bakery"');
319319 });
320
321 describe("what each app is doing", () => {
322 const ids = () => {
323 const maria = getUserByName(child.db, "maria")!;
324 const repo = child.db
325 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'")
326 .get()!;
327 return { maria, repo };
328 };
329 const seedIssue = (id: string, n: number, kind: string, title: string, state: string) => {
330 const { maria, repo } = ids();
331 child.db
332 .query(
333 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, ?4, ?5, '', ?6, ?7)",
334 )
335 .run(id, repo.id, n, kind, title, maria.id, state);
336 };
337 const clean = () => {
338 child.db.query("DELETE FROM run WHERE id LIKE 'r-doing-%'").run();
339 child.db.query("DELETE FROM pull WHERE issue_id LIKE 'i-doing-%'").run();
340 child.db.query("DELETE FROM issue WHERE id LIKE 'i-doing-%'").run();
341 };
342 const row = async (): Promise<string> => {
343 const home = main(await page(owner));
344 const from = home.indexOf('href="/marias/bakery"');
345 return home.slice(from, home.indexOf("</li>", from));
346 };
347
348 test("a quiet app gets no extra line", async () => {
349 clean();
350 expect(await row()).not.toContain("client-app-doing");
351 });
352
353 test("work running now reads Working on, escaped", async () => {
354 clean();
355 seedIssue("i-doing-1", 9001, "issue", "Add <b>rye</b> bread", "open");
356 child.db
357 .query("INSERT INTO run (id, repo_id, kind, state, issue_id) VALUES ('r-doing-1', ?1, 'agent', 'running', 'i-doing-1')")
358 .run(ids().repo.id);
359 const r = await row();
360 expect(r).toContain("Working on: Add &lt;b&gt;rye&lt;/b&gt; bread");
361 expect(r).not.toContain("<b>rye");
362 clean();
363 });
364
365 test("a change landed this week reads Last change with its age", async () => {
366 clean();
367 seedIssue("i-doing-2", 9002, "pull", "Fix the menu", "closed");
368 child.db
369 .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)")
370 .run("0".repeat(40), new Date(Date.now() - 3 * 86_400_000).toISOString());
371 expect(await row()).toContain("Last change: Fix the menu, 3 days ago");
372 clean();
373 });
374
375 test("the read is its own doing phase beside apps", async () => {
376 const res = await fetch(url("/"), { headers: { cookie: owner } });
377 const timing = res.headers.get("server-timing") ?? "";
378 expect(timing).toMatch(/\bdoing;/);
379 expect(timing).toMatch(/\bapps;/);
380 });
381
382 test("the terminal's line for the app carries it too", async () => {
383 clean();
384 seedIssue("i-doing-3", 9003, "issue", "Add rye bread", "open");
385 child.db
386 .query("INSERT INTO run (id, repo_id, kind, state, issue_id) VALUES ('r-doing-3', ?1, 'agent', 'running', 'i-doing-3')")
387 .run(ids().repo.id);
388 const terminal = headlessTerminal(160, 30);
389 const { app } = createForgeApp(
390 child,
391 { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: ids().maria },
392 { terminal, exitHooks: false },
393 );
394 app.start();
395 await settleApp(app);
396 expect(app.screen.toText()).toContain("Working on: Add rye bread");
397 app.stop();
398 clean();
399 });
400
401 test("titles are cut to 80 characters at a word boundary", () => {
402 const long = "word ".repeat(30);
403 const cut = cutTitle(long);
404 expect(cut.length).toBeLessThanOrEqual(80);
405 expect(cut.endsWith("word…")).toBe(true);
406 expect(cutTitle("short title")).toBe("short title");
407 expect(cutTitle("x".repeat(200))).toBe(`${"x".repeat(79)}…`);
408 expect(doingLine(undefined, 0)).toBe("");
409 });
410 });
320411 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs