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

The palette's Working now lists every running crew run on this repository with how long it has run, and choosing one opens it #7360

Merged opened this

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

Merged

scout/forge:scout/7351-the-palette-s-working-now-lists-every-ru → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7351-the-palette-s-working-now-lists-every-ru was merged into main as a596e27.

Review

Sign in to review this change.
4 files changed +128 −21
docs/APPS.md +3 −2
@@ -145,8 +145,9 @@ Under the box, four starter sentences are plain links (no script):
145145 write the app. A production comment opens an issue carrying the page address;
146146 a preview comment goes on its pull. From a terminal or an agent the same
147147 actions are, in the SSH terminal, rows of the palette on a repository
148 screen: *Working now* (while an agent run is live, left out when it is on work
149 you may not see), *Tell the crew*, *Report a problem* (one text, an issue
148 screen: *Working now* (one row per running agent run with its minutes, choosing
149 one opens the run's log; a run on work you may not see is left out),
150 *Tell the crew*, *Report a problem* (one text, an issue
150151 titled by its first line, through `openIssueThrough`) and *Make it yours*
151152 (the fork screen, for those who cannot write the repository), and *Vision*
152153 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
src/tui/screens/working.ts +32 −18
@@ -1,31 +1,45 @@
11 /**
22 * Working now, from a repository screen's `:` palette (#810): the terminal's
3 * door to the toolbar page's live line. The row is there while an agent run
4 * is running on this repository and names it; a run on work the person may
5 * not see (a sealed report) is left out, as on the page. Read when the
6 * palette opens, which is as often as the page reads it.
3 * door to the toolbar page's live line. One row per agent run running on
4 * this repository, naming the agent, the work and how long it has run;
5 * choosing one opens that run's address. A run on work the person may not
6 * see (a sealed report) is left out, as on the page. Read when the palette
7 * opens, which is as often as the page reads it.
78 */
89
10 import { getIssueById } from "../../core/issues.ts";
911 import type { Repo } from "../../core/repos.ts";
1012 import { listRuns } from "../../core/runs.ts";
1113 import { mayViewId } from "../../core/sealed.ts";
14 import { getUserById } from "../../core/users.ts";
1215 import type { Ctx } from "../app/app.ts";
1316 import type { Command } from "../app/palette.ts";
1417 import type { Session } from "../session.ts";
18 import { truncate } from "../term/width.ts";
19 import { openRunLog } from "./runlist.ts";
1520
1621 export function workingCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
17 if (repo === undefined || f.user === undefined) return [];
18 const live = listRuns(f.db, repo.id, { kind: "agent", state: "running", limit: 200 }).find((r) =>
19 mayViewId(f.db, repo, r.issueId, f.user),
20 );
21 if (live === undefined) return [];
22 const summary = live.summary || "the crew is on this";
23 return [
24 {
25 id: "working",
26 title: `${repo.ownerName}/${repo.name} — Working now: ${summary}`,
27 hint: "the crew is running",
28 run: () => ctx.toast(`Working now: ${summary}`, "success"),
29 },
30 ];
22 const viewer = f.user;
23 if (repo === undefined || viewer === undefined) return [];
24 const now = Date.now();
25 return listRuns(f.db, repo.id, { kind: "agent", state: "running", limit: 200 })
26 .filter((r) => mayViewId(f.db, repo, r.issueId, viewer))
27 .map((run) => {
28 const agent = run.actorId === null ? undefined : getUserById(f.db, run.actorId)?.username;
29 const issue = run.issueId === null ? undefined : getIssueById(f.db, run.issueId);
outdated

Floors to 0 min under a minute, and what is uncapped so a long title pushes the minutes off a narrow terminal. Cap what and word the sub-minute case.

30 const full =
31 issue !== undefined ? `#${issue.number} ${run.summary || issue.title}` : run.summary;
32 const on = full ? ` on ${truncate(full, 60)}` : "";
33 const since = Date.parse(run.startedAt ?? run.createdAt);
34 const minutes = Number.isFinite(since) ? Math.max(0, Math.floor((now - since) / 60_000)) : 0;
35 const line = `Working now: ${agent ?? "the crew"}${on} · ${
36 minutes < 1 ? "under a minute" : `${minutes} min`
37 }`;
38 return {
39 id: `working:${run.id}`,
40 title: `${repo.ownerName}/${repo.name} — ${line}`,
41 hint: "the crew is running",
42 run: () => openRunLog(f, ctx, repo, run),
43 };
44 });
3145 }
test/tui-working-yours.test.ts +1 −1
@@ -99,7 +99,7 @@ describe("working now in the terminal", () => {
9999 expect(await palette(bob, [":", "Working"])).not.toContain("Working now");
100100 const run = liveRun("LIVEJOB sanding the door");
101101 try {
102 expect(await palette(bob, [":", "Working"], "LIVEJOB")).toContain("Working now: LIVEJOB sanding the door");
102 expect(await palette(bob, [":", "Working"], "LIVEJOB")).toContain("LIVEJOB sanding the door");
103103 } finally {
104104 run.done();
105105 }
test/tui-working.test.ts added +92
@@ -0,0 +1,92 @@
1 /** Working now lists each visible running run with its minutes; choosing one opens it (#7351). */
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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { claimRun, createRun } from "../src/core/runs.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { workingCommands } from "../src/tui/screens/working.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { proveAll } from "./helpers/verified.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let app: App;
22 let alice: User;
23 let bob: User;
24 let repo: Repo;
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-tui-working2-"));
28 app = createApp({ dataDir: dir, port: 0 });
29 const mk = async (username: string, first = false) => {
30 const r = await createUser(app.db, {
31 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
32 password: "a-good-passphrase", adminIfFirst: first,
33 });
34 if (!r.ok) throw new Error(r.error);
35 return r.value;
36 };
37 alice = await mk("alice", true);
38 bob = await mk("bob");
39 proveAll(app);
40 const made = await createRepo(app, alice, { name: "lobby", visibility: "public" });
41 if (!made.ok) throw new Error(String(made.error));
42 repo = made.value;
43 });
44 afterAll(async () => {
45 app.db.close();
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 test("one entry per visible running run, with minutes; choosing opens the run", () => {
50 const open = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "door" });
51 const sealed = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "hole", sealed: true });
52 if (!open.ok || !sealed.ok) throw new Error("issue");
53 const shown = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: open.value.id, actorId: alice.id, summary: "sanding" });
54 const hidden = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: sealed.value.id, actorId: alice.id, summary: "SEALEDJOB" });
55 claimRun(app.db, shown.id);
56 claimRun(app.db, hidden.id);
57 app.db.run("UPDATE run SET started_at = ? WHERE id = ?", [new Date(Date.now() - 12 * 60_000 - 5000).toISOString(), shown.id]);
58
59 const { app: tui, session } = createForgeApp(
60 app,
61 { sshUsername: "bob", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: bob },
62 { terminal: headlessTerminal(120, 40), exitHooks: false },
63 );
64 const pushed: unknown[] = [];
65 const ctx = { ...tui.ctx, push: (screen: unknown) => void pushed.push(screen) } as typeof tui.ctx;
66 const cmds = workingCommands(session, ctx, repo);
67 expect(cmds).toHaveLength(1);
68 expect(cmds[0]!.title).toContain(`Working now: alice on #${open.value.number} sanding · 12 min`);
69 expect(cmds[0]!.title).not.toContain("SEALEDJOB");
70 cmds[0]!.run();
71 expect(pushed).toHaveLength(1);
outdated

Add a guest/no-user case and a run with no issue or actor; neither branch is covered.

72
73 expect(workingCommands(session, ctx, undefined)).toEqual([]);
74 expect(workingCommands({ ...session, user: undefined } as typeof session, ctx, repo)).toEqual([]);
75
76 // A run with no issue and no actor, under a minute, with a very long summary.
77 app.db.run("UPDATE run SET state = 'ok' WHERE id IN (?, ?)", [shown.id, hidden.id]);
78 const bare = createRun(app.db, { repoId: repo.id, kind: "agent", summary: "" });
79 claimRun(app.db, bare.id);
80 const bareCmds = workingCommands(session, ctx, repo);
81 expect(bareCmds).toHaveLength(1);
82 expect(bareCmds[0]!.title).toContain("Working now: the crew · under a minute");
83 app.db.run("UPDATE run SET state = 'ok' WHERE id = ?", [bare.id]);
84 const long = createRun(app.db, { repoId: repo.id, kind: "agent", summary: "x".repeat(300) });
85 claimRun(app.db, long.id);
86 const t = workingCommands(session, ctx, repo)[0]!.title;
87 expect(t.endsWith("· under a minute")).toBe(true);
88 expect(t.length).toBeLessThan(140);
89 pushed.length = 0;
90 workingCommands(session, ctx, repo)[0]!.run();
91 expect(pushed).toHaveLength(1);
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs