Public: anyone can read this; the crew can change it.
A run's summary is one escaped, clamped line wherever a row shows it #10208
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10199-a-run-s-summary-is-one-escaped-clamped-l → main 3 commits fork
13 files
changed +110
−12
docs/design/areas/runs.md +1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | # Runs |
| 2 | 2 | |
| 3 | - `.row-note` — free text an agent wrote (a run's summary, or a word's note taken from one) inside a §5 row's meta: one line clamped by CSS with an ellipsis, whitespace collapsed, escaped, never rendered as markdown, its whole text in `title`, and narrower than the meta so the who and when after it stay visible (#73). Written by `noteLine()` in `src/web/studio/note.ts` (the runs list, *Now*, a tell's runs, a planner's decline quoted on an ask's line and a decider's why in the board's decision ledger; #10199). Styled in `src/web/style/components.ts` (list rows). | |
| 3 | 4 | - `.check-words` — the run page's live tests line under the step list: reserves one line of height (1.4em) so the pin slot and log never move as the words arrive. |
| 4 | 5 | |
| 5 | 6 | ## A run's page at 390 px (#9347) |
src/web/pages/board.ts +2 −1
| @@ -102,6 +102,7 @@ import { | ||
| 102 | 102 | time, |
| 103 | 103 | tokenCount, |
| 104 | 104 | } from "../ui.ts"; |
| 105 | import { noteLine } from "../studio/note.ts"; | |
| 105 | 106 | import { tellWord } from "../studio/tell.ts"; |
| 106 | 107 | import { voiceFor } from "../studio/voice.ts"; |
| 107 | 108 | import { repoDir } from "../../git/repos.ts"; |
| @@ -931,7 +932,7 @@ async function decided(ctx: Ctx, scope: BoardScope): Promise<Html> { | ||
| 931 | 932 | title: answer, |
| 932 | 933 | meta: [ |
| 933 | 934 | `${KIND_WORDS[d.kind]} · ${issue?.title ?? repo.name} · ${d.by === "rule" ? "by the rule" : `by ${d.by}`}`, |
| 934 | hand === null ? null : html`<span title="${hand}">${hand}</span>`, | |
| 935 | hand === null ? null : noteLine(hand), | |
| 935 | 936 | ], |
| 936 | 937 | under: meter(sure, `${sure}% sure`), |
| 937 | 938 | trail: html`${ |
src/web/studio/asks.ts +4
| @@ -127,6 +127,10 @@ export interface Decision { | ||
| 127 | 127 | readonly says: string; |
| 128 | 128 | /** The same in one clause, for a row. */ |
| 129 | 129 | readonly short: string; |
| 130 | /** Free text a crew member wrote that the row quotes after `short` — a | |
| 131 | * planner's decline, from its run's summary. A row shows it through | |
| 132 | * `noteLine` (`./note.ts`): one escaped, clamped line (#10199). */ | |
| 133 | readonly note?: string | undefined; | |
| 130 | 134 | /** The option the decider thinks is right, and how sure; none for a rule. */ |
| 131 | 135 | readonly likely?: { readonly option: string; readonly p: number } | undefined; |
| 132 | 136 | /** The preview pictured on a phone, when the forge has taken one. */ |
src/web/studio/asks_page.ts +2 −1
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | ownerFactRepoIds, |
| 44 | 44 | } from "../../core/repos.ts"; |
| 45 | 45 | import { html, type Html } from "../html.ts"; |
| 46 | import { noteLine } from "./note.ts"; | |
| 46 | 47 | import { page, redirect } from "../layout.ts"; |
| 47 | 48 | import { btn, card, icon, row } from "../ui.ts"; |
| 48 | 49 | import { |
| @@ -161,7 +162,7 @@ const clause = ( | ||
| 161 | 162 | which?: string, |
| 162 | 163 | ): Html => |
| 163 | 164 | html`<p> |
| 164 | ${weight(c, which)}${d === undefined ? null : html`${d.short.charAt(0).toUpperCase()}${d.short.slice(1)}. `}<span class="muted" | |
| 165 | ${weight(c, which)}${d === undefined ? null : html`${d.short.charAt(0).toUpperCase()}${d.short.slice(1)}${d.note === undefined ? null : html`: \u201c${noteLine(d.note)}\u201d`}. `}<span class="muted" | |
| 165 | 166 | >If nobody acts, ${ASK_WORDS[kind].ifNothing}.</span |
| 166 | 167 | > |
| 167 | 168 | </p>`; |
src/web/studio/decisions.ts +4 −2
| @@ -19,6 +19,7 @@ import { html, type Html } from "../html.ts"; | ||
| 19 | 19 | import { btn } from "../ui.ts"; |
| 20 | 20 | import { thumbLook } from "../pages/looks.ts"; |
| 21 | 21 | import { changeHref } from "./nouns.ts"; |
| 22 | import { flatten } from "./note.ts"; | |
| 22 | 23 | import { testsClause } from "./words.ts"; |
| 23 | 24 | import { reviewerPaused } from "../../crew/reviewer_paused.ts"; |
| 24 | 25 | import { reviewWhy, scoredFor, waitingOn, type Decision, type Scored } from "./asks.ts"; |
| @@ -105,8 +106,9 @@ export function decisionsFor( | ||
| 105 | 106 | repo: c.repo, |
| 106 | 107 | issue: c.issue, |
| 107 | 108 | who: c.who, |
| 108 | says: `The planner declined the direction: \u201c${c.decline.words}\u201d. It plans nothing more until you answer on #${c.issue.number}.`, | |
| 109 | short: `the planner declined the direction: \u201c${c.decline.words}\u201d`, | |
| 109 | says: `The planner declined the direction: \u201c${flatten(c.decline.words)}\u201d. It plans nothing more until you answer on #${c.issue.number}.`, | |
| 110 | short: "the planner declined the direction", | |
| 111 | note: c.decline.words, | |
| 110 | 112 | }; |
| 111 | 113 | if (c.kind === "handover" && c.pull && c.handover) |
| 112 | 114 | return { |
src/web/studio/lists.ts +2 −1
| @@ -41,6 +41,7 @@ import { | ||
| 41 | 41 | signInHref, |
| 42 | 42 | } from "../chrome.ts"; |
| 43 | 43 | import { html, when, type Html } from "../html.ts"; |
| 44 | import { noteLine } from "./note.ts"; | |
| 44 | 45 | import { page } from "../layout.ts"; |
| 45 | 46 | import { liveClientJs, liveMount } from "../live.ts"; |
| 46 | 47 | import { |
| @@ -877,7 +878,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 877 | 878 | ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)} |
| 878 | 879 | ${when(durationText(run), (d) => html` · took ${d}`)} · |
| 879 | 880 | ${time(run.finishedAt ?? run.createdAt)}` |
| 880 | : html`${run.kind === "agent" ? html`${who} · ` : null}${run.state === "queued" ? whatRunWaitsOn(run) : info.note} | |
| 881 | : html`${run.kind === "agent" ? html`${who} · ` : null}${run.state === "queued" ? whatRunWaitsOn(run) : noteLine(info.note)} | |
| 881 | 882 | · ${time(run.finishedAt ?? run.createdAt)}`, |
| 882 | 883 | trail: wordBadge(info.word), |
| 883 | 884 | }); |
src/web/studio/note.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * A row is one line (#73). Free text an agent wrote — a run's summary, the | |
| 3 | * note a word carries from it — goes through here before it sits in a row's | |
| 4 | * meta: whitespace and newlines collapsed to single spaces, bounded | |
| 5 | * generously, escaped by `html`, and the whole of it in the title | |
| 6 | * attribute. Never rendered as markdown: a backtick stays a backtick. The | |
| 7 | * clamp itself is CSS (`.row-note` in `style/components.ts`), so a screen | |
| 8 | * reader and hover keep the text and the who and when beside it keep their | |
| 9 | * place. The transcript, one click from the row, has the whole summary. | |
| 10 | */ | |
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | ||
| 13 | /** The most characters of a note a row carries; past it the page would only grow. */ | |
| 14 | export const NOTE_MAX = 2000; | |
| 15 | ||
| 16 | /** One readable line: newlines and runs of space become a single space. */ | |
| 17 | export function flatten(text: string): string { | |
| 18 | const flat = text.replace(/\s+/g, " ").trim(); | |
| 19 | return flat.length > NOTE_MAX ? `${flat.slice(0, NOTE_MAX - 1)}…` : flat; | |
| 20 | } | |
| 21 | ||
| 22 | /** The note as a clamped, escaped span; the whole text is its title. */ | |
| 23 | export function noteLine(text: string): Html { | |
| 24 | const flat = flatten(text); | |
| 25 | return html`<span class="row-note" title="${flat}">${flat}</span>`; | |
| 26 | } |
src/web/studio/now.ts +6 −5
| @@ -24,6 +24,7 @@ import { readRunLogTail } from "../../runs/log.ts"; | ||
| 24 | 24 | import { mayReadRunLog } from "../../actions/runs.ts"; |
| 25 | 25 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 26 | 26 | import { html, type Html } from "../html.ts"; |
| 27 | import { flatten, noteLine } from "./note.ts"; | |
| 27 | 28 | import { fragment, missingFragment } from "../layout.ts"; |
| 28 | 29 | import { avatar, badge, btn, card, icon, listCard, row, time } from "../ui.ts"; |
| 29 | 30 | import { changeHref, tellHref } from "./nouns.ts"; |
| @@ -206,9 +207,9 @@ export async function nowCard( | ||
| 206 | 207 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), |
| 207 | 208 | lead: avatar(who), |
| 208 | 209 | title: stop |
| 209 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 210 | : (about?.title ?? note), | |
| 211 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 210 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? flatten(note)}</a>` | |
| 211 | : (about?.title ?? flatten(note)), | |
| 212 | meta: html`${who} · ${word === "working" ? "working now" : noteLine(note)} | |
| 212 | 213 | ${word === "working" |
| 213 | 214 | ? null |
| 214 | 215 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, |
| @@ -251,7 +252,7 @@ export async function nowCard( | ||
| 251 | 252 | nav: true, |
| 252 | 253 | lead: avatar(who), |
| 253 | 254 | title: i.title, |
| 254 | meta: html`${who} · ${note} · ${time(i.createdAt)}`, | |
| 255 | meta: html`${who} · ${noteLine(note)} · ${time(i.createdAt)}`, | |
| 255 | 256 | trail: wordBadge(word), |
| 256 | 257 | }); |
| 257 | 258 | }); |
| @@ -270,7 +271,7 @@ export async function nowCard( | ||
| 270 | 271 | nav: true, |
| 271 | 272 | lead: avatar(who), |
| 272 | 273 | title: p.title, |
| 273 | meta: html`${who} · ${note} · ${time(p.createdAt)}`, | |
| 274 | meta: html`${who} · ${noteLine(note)} · ${time(p.createdAt)}`, | |
| 274 | 275 | trail: wordBadge(word), |
| 275 | 276 | }); |
| 276 | 277 | }), |
src/web/studio/tell.ts +2 −2
| @@ -49,6 +49,7 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 49 | 49 | import type { Ctx } from "../../http/context.ts"; |
| 50 | 50 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 51 | 51 | import { html, when, type Html } from "../html.ts"; |
| 52 | import { noteLine } from "./note.ts"; | |
| 52 | 53 | import { threadLine } from "../describe.ts"; |
| 53 | 54 | import { page } from "../layout.ts"; |
| 54 | 55 | import { liveClientJs, liveMount } from "../live.ts"; |
| @@ -353,8 +354,7 @@ async function tellRuns( | ||
| 353 | 354 | nav: true, |
| 354 | 355 | lead: avatar(who), |
| 355 | 356 | title: html`${who} <span class="muted">${reasonWords(r.reason)}</span>`, |
| 356 | meta: html`${duration ? html`${duration} · ` : null}${r.summary || | |
| 357 | "no summary yet"}`, | |
| 357 | meta: html`${duration ? html`${duration} · ` : null}${noteLine(r.summary || "no summary yet")}`, | |
| 358 | 358 | trail: html`${wordBadge(word)}${when( |
| 359 | 359 | r.costUsd > 0, |
| 360 | 360 | () => |
src/web/style/components.ts +6
| @@ -466,6 +466,12 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 466 | 466 | font-variant-numeric: tabular-nums; |
| 467 | 467 | } |
| 468 | 468 | .row-meta .byline { display: inline; } |
| 469 | /* A note an agent wrote (#73): its own clamped line inside the meta, leaving | |
| 470 | room for the who and when after it; the whole text is the title. */ | |
| 471 | .row-note { | |
| 472 | display: inline-block; max-width: min(60ch, calc(100% - 14ch)); vertical-align: bottom; | |
| 473 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 474 | } | |
| 469 | 475 | .row-facts { display: flex; flex-wrap: wrap; gap: 0 16px; margin: 0; font-size: 12.5px; color: var(--muted-fg); line-height: 1.5; } |
| 470 | 476 | .row-facts > div { display: flex; gap: 6px; min-width: 0; } |
| 471 | 477 | .row-facts dt { font-weight: 550; } |
test/hand-decider.test.ts +2
| @@ -156,6 +156,8 @@ describe("let the decider answer a hand-over", () => { | ||
| 156 | 156 | const board = await home("/board"); |
| 157 | 157 | expect(board.includes("hand-over")).toBe(true); |
| 158 | 158 | expect(board.includes("Saw:")).toBe(true); |
| 159 | // Its why is the decider's own words: one clamped line, whole in its title (#10199). | |
| 160 | expect(board).toMatch(/<span class="row-note" title="[^"]*Saw:[^"]*">[^<]*Saw:/); | |
| 159 | 161 | const receipt = await home(res.headers.get("location")!); |
| 160 | 162 | expect(receipt.includes("90% sure")).toBe(true); |
| 161 | 163 | expect(receipt.includes(`/asks/decide/${row!.id}/undo`)).toBe(true); |
test/planner-declined.test.ts +10
| @@ -246,6 +246,16 @@ describe("the planner declined, waiting for a person", () => { | ||
| 246 | 246 | expect(after).not.toBe(before); |
| 247 | 247 | }); |
| 248 | 248 | |
| 249 | test("an ask quotes the planner's words as one escaped, clamped line, whole in its title (#10199)", async () => { | |
| 250 | const words = "nothing to plan:\n\n - `main` is **done**\n - see [the map](http://x.test) <b>now</b>"; | |
| 251 | decline(words, new Date().toISOString()); | |
| 252 | const asks = await page(await signIn("alice"), "/asks"); | |
| 253 | const flat = "nothing to plan: - `main` is **done** - see [the map](http://x.test) <b>now</b>"; | |
| 254 | expect(asks).toContain(`The planner declined the direction: \u201c<span class="row-note" title="${flat}">${flat}</span>\u201d.`); | |
| 255 | expect(asks).not.toContain("<b>now</b>"); | |
| 256 | expect(asks).not.toContain('href="http://x.test"'); | |
| 257 | }); | |
| 258 | ||
| 249 | 259 | test("an assisted repository never waits on its planner", () => { |
| 250 | 260 | app.db |
| 251 | 261 | .query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?1") |
test/studio.test.ts +43
| @@ -614,6 +614,49 @@ describe("the studio", () => { | ||
| 614 | 614 | expect(studio).toContain(`href="/alice/notes/runs/${run.id}"`); |
| 615 | 615 | }); |
| 616 | 616 | |
| 617 | test("a run's summary is one clamped line in its row: whole text in the title, markdown characters literal, the time after it", async () => { | |
| 618 | const scout = getUserByName(app.db, "scout")!; | |
| 619 | const run = createRun(app.db, { | |
| 620 | repoId: repo.id, | |
| 621 | kind: "agent", | |
| 622 | actorId: scout.id, | |
| 623 | reason: "assigned", | |
| 624 | }); | |
| 625 | claimRun(app.db, run.id); | |
| 626 | const report = Array.from( | |
| 627 | { length: 12 }, | |
| 628 | (_, i) => | |
| 629 | `line ${i}: **bold** \`main\` [link](http://x.test) <b>tag</b> done`, | |
| 630 | ).join("\n"); | |
| 631 | expect(report.length).toBeGreaterThan(600); | |
| 632 | finishRun(app.db, run.id, { state: "failed", summary: report }); | |
| 633 | // The run table keeps the first 500 characters; the row carries all of those. | |
| 634 | const flat = report.slice(0, 500).replace(/\s+/g, " ").trim(); | |
| 635 | ||
| 636 | const page = await (await get("/alice/notes/runs?state=failed")).text(); | |
| 637 | const at = page.indexOf(`href="/alice/notes/runs/${run.id}"`); | |
| 638 | expect(at).toBeGreaterThan(-1); | |
| 639 | const mine = page.slice(at, page.indexOf("</li>", at)); | |
| 640 | // One line: the note is its own clamped span, no newline survives in it. | |
| 641 | const span = /<span class="row-note" title="([^"]*)">([^<]*)<\/span>/.exec(mine); | |
| 642 | expect(span).not.toBeNull(); | |
| 643 | const unescape = (s: string) => | |
| 644 | s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); | |
| 645 | expect(unescape(span![1]!)).toBe(flat); | |
| 646 | expect(unescape(span![2]!)).toBe(flat); | |
| 647 | // Text, not source: markdown characters literal, markup escaped. | |
| 648 | expect(mine).toContain("**bold**"); | |
| 649 | expect(mine).toContain("`main`"); | |
| 650 | expect(mine).toContain("<b>tag</b>"); | |
| 651 | expect(mine).not.toContain("<b>tag</b>"); | |
| 652 | expect(mine).not.toContain("<strong>"); | |
| 653 | expect(mine).not.toContain('href="http://x.test"'); | |
| 654 | // The when comes after the note, outside its clamp. | |
| 655 | expect(mine.slice(mine.indexOf("</span>", mine.indexOf("row-note")))).toContain("<time"); | |
| 656 | const css = await (await fetch(`${base}${stylesheetAsset().path}`)).text(); | |
| 657 | expect(css).toMatch(/\.row-note \{[^}]*text-overflow: ellipsis[^}]*white-space: nowrap/); | |
| 658 | }); | |
| 659 | ||
| 617 | 660 | test("a change that no longer merges into its base says so, and offers no Ship — a mergeable green pull is unchanged", async () => { |
| 618 | 661 | const scout = getUserByName(app.db, "scout")!; |
| 619 | 662 | const base1 = await commitToBranch(app, alice, repo, { |