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

Merged opened this

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

Merged

mason/forge:mason/10199-a-run-s-summary-is-one-escaped-clamped-l → main 3 commits fork

  • Merged. mason/10199-a-run-s-summary-is-one-escaped-clamped-l was merged into main as 7a3b853.

Review

Sign in to review this change.
13 files changed +110 −12
docs/design/areas/runs.md +1
@@ -1,5 +1,6 @@
11 # Runs
22
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).
34 - `.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.
45
56 ## A run's page at 390 px (#9347)
src/web/pages/board.ts +2 −1
@@ -102,6 +102,7 @@ import {
102102 time,
103103 tokenCount,
104104 } from "../ui.ts";
105 import { noteLine } from "../studio/note.ts";
105106 import { tellWord } from "../studio/tell.ts";
106107 import { voiceFor } from "../studio/voice.ts";
107108 import { repoDir } from "../../git/repos.ts";
@@ -931,7 +932,7 @@ async function decided(ctx: Ctx, scope: BoardScope): Promise<Html> {
931932 title: answer,
932933 meta: [
933934 `${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),
935936 ],
936937 under: meter(sure, `${sure}% sure`),
937938 trail: html`${
src/web/studio/asks.ts +4
@@ -127,6 +127,10 @@ export interface Decision {
127127 readonly says: string;
128128 /** The same in one clause, for a row. */
129129 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;
130134 /** The option the decider thinks is right, and how sure; none for a rule. */
131135 readonly likely?: { readonly option: string; readonly p: number } | undefined;
132136 /** 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 {
4343 ownerFactRepoIds,
4444 } from "../../core/repos.ts";
4545 import { html, type Html } from "../html.ts";
46 import { noteLine } from "./note.ts";
4647 import { page, redirect } from "../layout.ts";
4748 import { btn, card, icon, row } from "../ui.ts";
4849 import {
@@ -161,7 +162,7 @@ const clause = (
161162 which?: string,
162163 ): Html =>
163164 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"
165166 >If nobody acts, ${ASK_WORDS[kind].ifNothing}.</span
166167 >
167168 </p>`;
src/web/studio/decisions.ts +4 −2
@@ -19,6 +19,7 @@ import { html, type Html } from "../html.ts";
1919 import { btn } from "../ui.ts";
2020 import { thumbLook } from "../pages/looks.ts";
2121 import { changeHref } from "./nouns.ts";
22 import { flatten } from "./note.ts";
2223 import { testsClause } from "./words.ts";
2324 import { reviewerPaused } from "../../crew/reviewer_paused.ts";
2425 import { reviewWhy, scoredFor, waitingOn, type Decision, type Scored } from "./asks.ts";
@@ -105,8 +106,9 @@ export function decisionsFor(
105106 repo: c.repo,
106107 issue: c.issue,
107108 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,
110112 };
111113 if (c.kind === "handover" && c.pull && c.handover)
112114 return {
src/web/studio/lists.ts +2 −1
@@ -41,6 +41,7 @@ import {
4141 signInHref,
4242 } from "../chrome.ts";
4343 import { html, when, type Html } from "../html.ts";
44 import { noteLine } from "./note.ts";
4445 import { page } from "../layout.ts";
4546 import { liveClientJs, liveMount } from "../live.ts";
4647 import {
@@ -877,7 +878,7 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
877878 ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)}
878879 ${when(durationText(run), (d) => html` · took ${d}`)} ·
879880 ${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)}
881882 · ${time(run.finishedAt ?? run.createdAt)}`,
882883 trail: wordBadge(info.word),
883884 });
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";
2424 import { mayReadRunLog } from "../../actions/runs.ts";
2525 import { loadRepo, repoBase } from "../chrome.ts";
2626 import { html, type Html } from "../html.ts";
27 import { flatten, noteLine } from "./note.ts";
2728 import { fragment, missingFragment } from "../layout.ts";
2829 import { avatar, badge, btn, card, icon, listCard, row, time } from "../ui.ts";
2930 import { changeHref, tellHref } from "./nouns.ts";
@@ -206,9 +207,9 @@ export async function nowCard(
206207 ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }),
207208 lead: avatar(who),
208209 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)}
212213 ${word === "working"
213214 ? null
214215 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
@@ -251,7 +252,7 @@ export async function nowCard(
251252 nav: true,
252253 lead: avatar(who),
253254 title: i.title,
254 meta: html`${who} · ${note} · ${time(i.createdAt)}`,
255 meta: html`${who} · ${noteLine(note)} · ${time(i.createdAt)}`,
255256 trail: wordBadge(word),
256257 });
257258 });
@@ -270,7 +271,7 @@ export async function nowCard(
270271 nav: true,
271272 lead: avatar(who),
272273 title: p.title,
273 meta: html`${who} · ${note} · ${time(p.createdAt)}`,
274 meta: html`${who} · ${noteLine(note)} · ${time(p.createdAt)}`,
274275 trail: wordBadge(word),
275276 });
276277 }),
src/web/studio/tell.ts +2 −2
@@ -49,6 +49,7 @@ import { repoDir } from "../../git/repos.ts";
4949 import type { Ctx } from "../../http/context.ts";
5050 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
5151 import { html, when, type Html } from "../html.ts";
52 import { noteLine } from "./note.ts";
5253 import { threadLine } from "../describe.ts";
5354 import { page } from "../layout.ts";
5455 import { liveClientJs, liveMount } from "../live.ts";
@@ -353,8 +354,7 @@ async function tellRuns(
353354 nav: true,
354355 lead: avatar(who),
355356 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")}`,
358358 trail: html`${wordBadge(word)}${when(
359359 r.costUsd > 0,
360360 () =>
src/web/style/components.ts +6
@@ -466,6 +466,12 @@ a.row:hover .row-title { color: var(--accent); }
466466 font-variant-numeric: tabular-nums;
467467 }
468468 .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 }
469475 .row-facts { display: flex; flex-wrap: wrap; gap: 0 16px; margin: 0; font-size: 12.5px; color: var(--muted-fg); line-height: 1.5; }
470476 .row-facts > div { display: flex; gap: 6px; min-width: 0; }
471477 .row-facts dt { font-weight: 550; }
test/hand-decider.test.ts +2
@@ -156,6 +156,8 @@ describe("let the decider answer a hand-over", () => {
156156 const board = await home("/board");
157157 expect(board.includes("hand-over")).toBe(true);
158158 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:/);
159161 const receipt = await home(res.headers.get("location")!);
160162 expect(receipt.includes("90% sure")).toBe(true);
161163 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", () => {
246246 expect(after).not.toBe(before);
247247 });
248248
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) &lt;b&gt;now&lt;/b&gt;";
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
249259 test("an assisted repository never waits on its planner", () => {
250260 app.db
251261 .query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?1")
test/studio.test.ts +43
@@ -614,6 +614,49 @@ describe("the studio", () => {
614614 expect(studio).toContain(`href="/alice/notes/runs/${run.id}"`);
615615 });
616616
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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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("&lt;b&gt;tag&lt;/b&gt;");
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
617660 test("a change that no longer merges into its base says so, and offers no Ship — a mergeable green pull is unchanged", async () => {
618661 const scout = getUserByName(app.db, "scout")!;
619662 const base1 = await commitToBranch(app, alice, repo, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs