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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7351-the-palette-s-working-now-lists-every-ru → main 4 commits fork 2 threads · 2 unresolved
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): | ||
| 145 | 145 | write the app. A production comment opens an issue carrying the page address; |
| 146 | 146 | a preview comment goes on its pull. From a terminal or an agent the same |
| 147 | 147 | 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 | |
| 150 | 151 | titled by its first line, through `openIssueThrough`) and *Make it yours* |
| 151 | 152 | (the fork screen, for those who cannot write the repository), and *Vision* |
| 152 | 153 | (docs/VISION.md on the default branch: the purpose, each pillar numbered, |
src/tui/screens/working.ts +32 −18
| @@ -1,31 +1,45 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 10 | import { getIssueById } from "../../core/issues.ts"; | |
| 9 | 11 | import type { Repo } from "../../core/repos.ts"; |
| 10 | 12 | import { listRuns } from "../../core/runs.ts"; |
| 11 | 13 | import { mayViewId } from "../../core/sealed.ts"; |
| 14 | import { getUserById } from "../../core/users.ts"; | |
| 12 | 15 | import type { Ctx } from "../app/app.ts"; |
| 13 | 16 | import type { Command } from "../app/palette.ts"; |
| 14 | 17 | import type { Session } from "../session.ts"; |
| 18 | import { truncate } from "../term/width.ts"; | |
| 19 | import { openRunLog } from "./runlist.ts"; | |
| 15 | 20 | |
| 16 | 21 | 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); | |
| 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 | }); | |
| 31 | 45 | } |
test/tui-working-yours.test.ts +1 −1
| @@ -99,7 +99,7 @@ describe("working now in the terminal", () => { | ||
| 99 | 99 | expect(await palette(bob, [":", "Working"])).not.toContain("Working now"); |
| 100 | 100 | const run = liveRun("LIVEJOB sanding the door"); |
| 101 | 101 | 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"); | |
| 103 | 103 | } finally { |
| 104 | 104 | run.done(); |
| 105 | 105 | } |
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 | }); |
new above
offline
Floors to 0 min under a minute, and
whatis uncapped so a long title pushes the minutes off a narrow terminal. Capwhatand word the sub-minute case.