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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4008-on-a-client-s-first-screen-each-app-row → main 2 commits fork 2 threads · 2 unresolved
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 | ||
| 337 | 337 | link), the home is their apps, one plain sentence each and one more saying |
| 338 | 338 | whether its agents are working, about to start, or waiting: no map, board or |
| 339 | 339 | 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`). | |
| 342 | 344 | |
| 343 | 345 | Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with |
| 344 | 346 | 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | import { ago } from "../feed/rank.ts"; | |
| 9 | 10 | import { spawnedRepos } from "./next-steps.ts"; |
| 10 | 11 | import { withoutArchived, type Repo } from "./repos.ts"; |
| 11 | 12 | import type { User } from "./users.ts"; |
| @@ -94,3 +95,27 @@ export function clientDoings( | ||
| 94 | 95 | } |
| 95 | 96 | return doings; |
| 96 | 97 | } |
| 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"; | ||
| 20 | 20 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; |
| 21 | 21 | import { forgeHealth } from "../../kernel/guard.ts"; |
| 22 | 22 | 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"; | |
| 24 | 24 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 25 | 25 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 26 | 26 | import { mayView } from "../../core/sealed.ts"; |
| @@ -186,12 +186,16 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 186 | 186 | // A client's home never carries the forge-wide figures, on either door. |
| 187 | 187 | figures = []; |
| 188 | 188 | 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); | |
| 189 | 191 | clientLines = |
| 190 | 192 | apps.length === 0 |
| 191 | 193 | ? ["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}`; | |
| 198 | }); | |
| 195 | 199 | ctx.invalidate(); |
| 196 | 200 | }; |
| 197 | 201 |
src/web/studio/client_home.ts +8 −2
| @@ -6,8 +6,8 @@ | ||
| 6 | 6 | * stay off it; `test/client-home.test.ts` holds the list. |
| 7 | 7 | */ |
| 8 | 8 | |
| 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"; | |
| 11 | 11 | import type { User } from "../../core/users.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { repoBase } from "../chrome.ts"; |
| @@ -28,8 +28,13 @@ export function agentsLine(working: number, queued: number): string { | ||
| 28 | 28 | export async function clientApps(ctx: Ctx, user: User): Promise<Html> { |
| 29 | 29 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 30 | 30 | 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 | ); | |
| 31 | 35 | const rendering = performance.now(); |
| 32 | 36 | const rows = apps.map(({ repo, working, queued }) => { |
| 37 | const doing = doingLine(doings.get(repo.id), now.getTime()); | |
| 33 | 38 | const about = repo.description !== "" ? repo.description : "An app of yours."; |
| 34 | 39 | return html`<li class="client-app"> |
| 35 | 40 | <a href="${repoBase(repo)}"><b>${repo.name}</b></a> |
| @@ -37,6 +42,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | ||
| 37 | 42 | <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}" |
| 38 | 43 | >${agentsLine(working, queued)}</span |
| 39 | 44 | > |
| 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. |
||
| 40 | 46 | </li>`; |
| 41 | 47 | }); |
| 42 | 48 | const shown = card( |
test/client-home.test.ts +92 −1
| @@ -12,7 +12,7 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; |
| 17 | 17 | import { moveArchived } from "../src/core/repos.ts"; |
| 18 | 18 | import { createSession } from "../src/core/sessions.ts"; |
| @@ -317,4 +317,95 @@ describe("a client's first screen", () => { | ||
| 317 | 317 | expect(await names()).toEqual(["bakery", "cafe"]); |
| 318 | 318 | expect(await apps()).toContain('href="/marias/bakery"'); |
| 319 | 319 | }); |
| 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 <b>rye</b> 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 | }); | |
| 320 | 411 | }); |
new above
offline
Joined onto the same line as the agents sentence, this can pass 100 columns; consider a second line per app.