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

A crew run's page fits a 390 px phone: its log lines and file paths wrap or scroll inside their box, never the page #9359

Merged opened this

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

Merged

mason/forge:mason/9347-a-crew-run-s-page-fits-a-390-px-phone-it → main 3 commits fork

  • Merged. mason/9347-a-crew-run-s-page-fits-a-390-px-phone-it was merged into main as 831ae6a.

Review

Sign in to review this change.
4 files changed +125 −1
docs/design/areas/runs.md +13
@@ -1,3 +1,16 @@
11 # Runs
22
33 - `.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 ## A run's page at 390 px (#9347)
6
7 `/:owner/:name/runs/:id` fits a 390 px phone. The head (`#run-head`: title,
8 the issue it is about, summary, carries) and the live region (`#run-live`)
9 break a long word anywhere (`RUN_PAGE_CSS`, `src/web/pages/runs.ts`, shipped
10 as the page's own style), so a pasted path or a 300-character token wraps in
11 place. The log box (`.run-log`) already wraps its lines and scrolls inside its
12 own bounds. No fixed width; the page does not scroll sideways. No new class.
13
14 See it on a phone: `/preview-admin/sample/runs/<id>` renders a sample run's
15 page; at 390 px the head and live region wrap in place. That is asserted from
16 the HTML and CSS (`test/run-page-fit.test.ts`), not from a 390 px render.
src/web/pages/runs.ts +10
@@ -48,6 +48,16 @@ import {
4848 tokenCount,
4949 } from "../ui.ts";
5050
51 /**
52 * A run's page at a 390 px phone (#9347): its head (title, the issue it is
53 * about, summary, what it carries) and everything under the live region
54 * break a long word anywhere, so a pasted path or a 300-character token
55 * wraps in place and the page never scrolls sideways. The log box itself
56 * already wraps and scrolls inside its own bounds (`.run-log`,
57 * `style/content.ts`). Ids the page already carries; no new class.
58 */
59 export const RUN_PAGE_CSS = `#run-head, #run-live { overflow-wrap: anywhere; }`;
60
5161 // ---- words ----
5262
5363 /** The pull a preview environment is for, from its name; production has none. */
src/web/studio/run.ts +2 −1
@@ -32,7 +32,7 @@ import { runLine } from "../describe.ts";
3232 import { page } from "../layout.ts";
3333 import { badge, btn, card, listCard, row as stepRow, sha, type Tone } from "../ui.ts";
3434 import { previewCard } from "../pages/apps.ts";
35 import { costText, durationText, runTitle, runTokens } from "../pages/runs.ts";
35 import { costText, durationText, RUN_PAGE_CSS, runTitle, runTokens } from "../pages/runs.ts";
3636 import { placementLine, sandboxLine } from "./sandbox.ts";
3737 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3838 import { changeHref, tellHref } from "./nouns.ts";
@@ -442,6 +442,7 @@ export async function runView(
442442 crumbs: crumbsFor(repo),
443443 palette: repoPalette(ctx, repo),
444444 body,
445 styleCss: RUN_PAGE_CSS,
445446 scriptJs: `${liveRegionsJs}\n${studioRunJs}`,
446447 });
447448 }
test/run-page-fit.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * A crew run's page fits a 390 px phone (#9347): seeded long content — a
3 * 300-character unbroken word in the summary and the log, a long issue title,
4 * a long file path, many log lines — ships no fixed width over 390 px; the
5 * head and the live region break long words anywhere, and the log box wraps
6 * and scrolls inside its own bounds, never the page.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { MAX_TITLE } from "../src/core/issues.ts";
16 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { appendRunLog } from "../src/runs/log.ts";
20 import { RUN_PAGE_CSS } from "../src/web/pages/runs.ts";
21 import { utilities as base } from "../src/web/style/base.ts";
22 import { components } from "../src/web/style/components.ts";
23 import { content } from "../src/web/style/content.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25
26 const WORD = "W".repeat(300);
27 const TITLE = "T".repeat(MAX_TITLE - 10);
28 const PATH = `src/${"deeply-nested-directory/".repeat(12)}file-with-a-long-name.ts`;
29 let dir: string;
30 let app: App;
31 let servers: Servers;
32 let page: string;
33
34 beforeAll(async () => {
35 dir = await mkdtemp(join(tmpdir(), "forge-run-fit-"));
36 app = createApp({ dataDir: dir, port: 0 });
37 servers = await serve(app);
38 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
39 if (!made.ok) throw new Error(made.error);
40 const u = made.value;
41 const repo = await createRepo(app, u, { name: "fit", visibility: "public" });
42 if (!repo.ok) throw new Error(String(repo.error));
43 const issue = openIssue(app, u, repo.value, { title: TITLE, body: "b" });
44 if (!issue.ok) throw new Error(String(issue.error));
45 const run = createRun(app.db, { repoId: repo.value.id, kind: "agent", issueId: issue.value.id, actorId: u.id, reason: "assigned" });
46 claimRun(app.db, run.id);
47 appendRunLog(dir, run.id, WORD);
48 appendRunLog(dir, run.id, `→ Read ${PATH}`);
49 appendRunLog(dir, run.id, `→ Bash ${WORD}`);
50 for (let i = 0; i < 60; i++) appendRunLog(dir, run.id, `line ${i}: edited ${PATH}`);
51 finishRun(app.db, run.id, { state: "ok", summary: `Changed ${WORD}`, costUsd: 1.25 });
52 const cookie = `forge_session=${createSession(app.db, u.id).token}`;
53 const res = await fetch(`${servers.url}/alice/fit/runs/${run.id}`, { headers: { cookie } });
54 expect(res.status).toBe(200);
55 page = await res.text();
56 }, 60_000);
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dir, { recursive: true, force: true });
61 });
62
63 const styles = (): string => (page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? []).join("\n");
64
65 describe("a crew run's page at 390 px", () => {
66 test("the long content is on the page, in many log rows", () => {
67 expect(page).toContain(WORD);
68 expect(page).toContain(TITLE);
69 expect(page).toContain(PATH);
70 expect(page.match(/data-line="say"/g)?.length ?? 0).toBeGreaterThanOrEqual(60);
71 });
72
73 test("no inline width and no shipped rule fixes a width over 390 px", () => {
74 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
75 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
76 const over: string[] = [];
77 for (const css of [base, components, content, styles()]) {
78 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
79 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
80 }
81 }
82 expect(over).toEqual([]);
83 });
84
85 test("the head and the live region break long words anywhere", () => {
86 expect(page).toContain('id="run-head"');
87 expect(page).toContain('id="run-live"');
88 expect(styles()).toContain(RUN_PAGE_CSS);
89 expect(RUN_PAGE_CSS).toMatch(/#run-head\b[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
90 expect(RUN_PAGE_CSS).toMatch(/#run-live\b[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
91 });
92
93 test("the log box wraps its lines and scrolls inside itself", () => {
94 expect(page).toMatch(/class="run-log"[^>]*id="run-log"/);
95 const rule = content.match(/\.run-log \{[^}]*\}/)?.[0] ?? "";
96 expect(rule).toMatch(/overflow-wrap:\s*anywhere/);
97 expect(rule).toMatch(/overflow:\s*auto/);
98 expect(content).toMatch(/\.run-log \.row-title \{[^}]*white-space:\s*pre-wrap/);
99 });
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs