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

The browser tab still says "Issues" and "Pull requests" on every studio page #311

Merged opened this

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

Merged

mason/forge:mason/262-the-browser-tab-still-says-issues-and-pu → main 2 commits fork

  • Merged. mason/262-the-browser-tab-still-says-issues-and-pu was merged into main as 751cbca.

Review

Sign in to review this change.
8 files changed +216 −52
src/http/server.ts +2
@@ -75,6 +75,7 @@ import {
7575 issueThreadFragment,
7676 newIssuePage,
7777 newIssueSubmit,
78 newTellPage,
7879 tellPage,
7980 } from "../web/pages/issues.ts";
8081 import { globalMapPage, mapPage } from "../web/pages/map.ts";
@@ -549,6 +550,7 @@ export function buildRouter(): Router<Handler> {
549550 // apply). The literal /tells is registered before /tells/:number for the
550551 // same reason /issues/new is.
551552 router.add("GET", "/:owner/:name/tells", tellsList);
553 router.add("GET", "/:owner/:name/tells/new", newTellPage);
552554 router.add("GET", "/:owner/:name/tells/:number", tellPage);
553555 router.add("GET", "/:owner/:name/issues/new", newIssuePage);
554556 router.add("POST", "/:owner/:name/issues/new", newIssueSubmit);
src/web/pages/issues.ts +62 −33
@@ -63,7 +63,12 @@ import {
6363 } from "./thread.ts";
6464 import { crewActivityCard } from "./runs.ts";
6565 import { studioTells } from "../studio/lists.ts";
66 import { studioRecord } from "../studio/nouns.ts";
66 import {
67 PAGE_TITLES,
68 pageTitle,
69 studioRecord,
70 tellsHref,
71 } from "../studio/nouns.ts";
6772 import { studioTell } from "../studio/tell.ts";
6873
6974 const PER_PAGE = 25;
@@ -361,7 +366,7 @@ export async function issuesList(ctx: Ctx): Promise<Response> {
361366
362367 return page({
363368 ctx,
364 title: `Issues · ${repo.name}`,
369 title: pageTitle("tells", repo, false),
365370 crumbs: scope.crumbs,
366371 palette: scope.palette,
367372 body,
@@ -372,9 +377,49 @@ export async function issuesList(ctx: Ctx): Promise<Response> {
372377
373378 // ---- compose ----
374379
380 /**
381 * The compose page, in the face the viewer has: the studio's own noun for
382 * it (`PAGE_TITLES.compose`) on the tab, the heading and the button, and
383 * Cancel back to the list that face calls home. The form posts to
384 * `/issues/new` in both faces — the one door that changes something.
385 */
386 function composePage(
387 ctx: Ctx,
388 repo: Repo,
389 values: { title: string; body: string },
390 error?: string,
391 ): Response {
392 const studio = studioRecord(ctx, repo) !== undefined;
393 const heading = PAGE_TITLES.compose[studio ? "studio" : "dev"];
394 const base = repoBase(repo);
395 return page(
396 {
397 ctx,
398 title: pageTitle("compose", repo, studio),
399 crumbs: crumbsFor(repo),
400 palette: repoPalette(ctx, repo),
401 body: html`${repoHeader(ctx, repo, "issues", {
402 compact: true,
403 ownTitle: true,
404 })}
405 <h1 class="mt">${heading}</h1>
406 ${newIssueForm(
407 repo,
408 values,
409 studio ? "Tell" : "Create issue",
410 studio ? tellsHref(base) : `${base}/issues`,
411 error,
412 )}`,
413 },
414 error === undefined ? 200 : 422,
415 );
416 }
417
375418 function newIssueForm(
376419 repo: Repo,
377420 values: { title: string; body: string },
421 submit: string,
422 cancelHref: string,
378423 error?: string,
379424 ): Html {
380425 return html`<div class="narrow-wide mt">
@@ -397,11 +442,8 @@ function newIssueForm(
397442 hint: "Markdown is supported.",
398443 })}
399444 <div class="form-actions">
400 ${btn("Create issue", { variant: "primary", icon: "issue" })}
401 ${btn("Cancel", {
402 href: `${repoBase(repo)}/issues`,
403 variant: "ghost",
404 })}
445 ${btn(submit, { variant: "primary", icon: "issue" })}
446 ${btn("Cancel", { href: cancelHref, variant: "ghost" })}
405447 </div>
406448 </form>
407449 </div>`;
@@ -412,18 +454,18 @@ export function newIssuePage(ctx: Ctx): Response {
412454 const repo = loadRepo(ctx);
413455 if (!repo) return notFoundPage(ctx);
414456 if (!ctx.auth.user) return signInFirst(ctx);
415 return page({
416 ctx,
417 title: `New issue · ${repo.name}`,
418 crumbs: crumbsFor(repo),
419 palette: repoPalette(ctx, repo),
420 body: html`${repoHeader(ctx, repo, "issues", {
421 compact: true,
422 ownTitle: true,
423 })}
424 <h1 class="mt">New issue</h1>
425 ${newIssueForm(repo, { title: "", body: "" })}`,
426 });
457 return composePage(ctx, repo, { title: "", body: "" });
458 }
459
460 // GET /:owner/:name/tells/new — the studio's own door onto the compose page
461 // `/issues/new` reaches, redirecting there when the studio does not apply,
462 // the same rule `tellPage` follows for one tell.
463 export function newTellPage(ctx: Ctx): Response {
464 const repo = loadRepo(ctx);
465 if (!repo) return notFoundPage(ctx);
466 if (studioRecord(ctx, repo) === undefined)
467 return redirect(`${repoBase(repo)}/issues/new${ctx.url.search}`);
468 return newIssuePage(ctx);
427469 }
428470
429471 // POST /:owner/:name/issues/new
@@ -449,20 +491,7 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
449491 : created.error === "body_too_long"
450492 ? "That description is too long (64 KB at most)."
451493 : "You cannot open an issue here.");
452 return page(
453 {
454 ctx,
455 title: `New issue · ${repo.name}`,
456 crumbs: crumbsFor(repo),
457 body: html`${repoHeader(ctx, repo, "issues", {
458 compact: true,
459 ownTitle: true,
460 })}
461 <h1 class="mt">New issue</h1>
462 ${newIssueForm(repo, values, message)}`,
463 },
464 422,
465 );
494 return composePage(ctx, repo, values, message);
466495 }
467496 return flashTo(ctx, `${repoBase(repo)}/issues/${created.value.number}`, {
468497 message: `Issue #${created.value.number} opened.`,
src/web/pages/pulls.ts +2 −2
@@ -53,7 +53,7 @@ import {
5353 import { refSet } from "./refs.ts";
5454 import { threadRow } from "./issues.ts";
5555 import { studioChanges } from "../studio/lists.ts";
56 import { studioRecord } from "../studio/nouns.ts";
56 import { pageTitle, studioRecord } from "../studio/nouns.ts";
5757 import { originGlyph } from "./thread.ts";
5858
5959 const PER_PAGE = 25;
@@ -276,7 +276,7 @@ export async function pullsList(ctx: Ctx): Promise<Response> {
276276
277277 return page({
278278 ctx,
279 title: `Pull requests · ${repo.name}`,
279 title: pageTitle("changes", repo, false),
280280 crumbs: crumbsFor(repo),
281281 palette: repoPalette(ctx, repo),
282282 body,
src/web/pages/runs.ts +2 −2
@@ -37,7 +37,7 @@ import { html, when, type Html } from "../html.ts";
3737 import { flashTo, page, redirect } from "../layout.ts";
3838 import { liveClientJs, liveMount } from "../live.ts";
3939 import { studioRuns } from "../studio/lists.ts";
40 import { studioRecord } from "../studio/nouns.ts";
40 import { pageTitle, studioRecord } from "../studio/nouns.ts";
4141 import { studioRun } from "../studio/run.ts";
4242 import {
4343 btn,
@@ -332,7 +332,7 @@ export async function runsList(ctx: Ctx): Promise<Response> {
332332
333333 return page({
334334 ctx,
335 title: `Runs · ${repo.name}`,
335 title: pageTitle("runs", repo, false),
336336 crumbs: crumbsFor(repo),
337337 palette: repoPalette(ctx, repo),
338338 body,
src/web/studio/crew.ts +3 −2
@@ -26,6 +26,7 @@ import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
2626 import { html, raw, type Html } from "../html.ts";
2727 import { icon } from "../icons.ts";
2828 import { page } from "../layout.ts";
29 import { pageTitle } from "./nouns.ts";
2930 import {
3031 btn,
3132 card,
@@ -224,7 +225,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
224225 name: "ceiling",
225226 value: String(repo.autonomyCeilingUsd),
226227 type: "number",
227 hint: "Dollars of agent spend on this repository per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.",
228 hint: "Dollars of agent spend on this app per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.",
228229 })}
229230 ${field({
230231 label: "Slots",
@@ -271,7 +272,7 @@ export function studioCrewSettings(
271272 </div>`;
272273 return page({
273274 ctx,
274 title: `Settings · ${repo.name}`,
275 title: pageTitle("settings", repo, true),
275276 crumbs: crumbsFor(repo),
276277 palette: repoPalette(ctx, repo),
277278 body,
src/web/studio/lists.ts +9 −4
@@ -37,7 +37,12 @@ import {
3737 time,
3838 } from "../ui.ts";
3939 import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts";
40 import { changeHref, studioRecord, tellHref } from "./nouns.ts";
40 import {
41 changeHref,
42 pageTitle,
43 studioRecord,
44 tellHref,
45 } from "./nouns.ts";
4146 import { tellWord } from "./tell.ts";
4247 import {
4348 pullWord,
@@ -201,7 +206,7 @@ export async function studioTells(
201206
202207 return page({
203208 ctx,
204 title: `Everything told · ${repo.name}`,
209 title: pageTitle("tells", repo, true),
205210 crumbs: crumbsFor(repo),
206211 palette: repoPalette(ctx, repo),
207212 body,
@@ -289,7 +294,7 @@ export async function studioChanges(
289294
290295 return page({
291296 ctx,
292 title: `Every change · ${repo.name}`,
297 title: pageTitle("changes", repo, true),
293298 crumbs: crumbsFor(repo),
294299 palette: repoPalette(ctx, repo),
295300 body,
@@ -403,7 +408,7 @@ export async function studioRuns(
403408
404409 return page({
405410 ctx,
406 title: `Every run · ${repo.name}`,
411 title: pageTitle("runs", repo, true),
407412 crumbs: crumbsFor(repo),
408413 palette: repoPalette(ctx, repo),
409414 body,
src/web/studio/nouns.ts +29
@@ -47,3 +47,32 @@ export function studioRecord(ctx: Ctx, repo: Repo): AppRecord | undefined {
4747 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
4848 return studio ? getAppByRepo(ctx.app.db, repo.id) : undefined;
4949 }
50
51 /**
52 * What each page a studio viewer can reach calls itself in the browser tab
53 * — the bookmark, the history entry, the first thing a screen reader says
54 * on arrival — in both faces, decided here beside the addresses so a page
55 * never carries a second literal of its own. `studio` is the word when
56 * `studioRecord` gives the page a studio; `dev` is today's word for dev and
57 * for any repository that is not an app. A single tell, change or run
58 * titles itself by its own title, and the app page by the app's name, so
59 * none of those is here.
60 */
61 export const PAGE_TITLES = {
62 tells: { studio: "Everything told", dev: "Issues" },
63 changes: { studio: "Every change", dev: "Pull requests" },
64 runs: { studio: "Every run", dev: "Runs" },
65 compose: { studio: "Tell the crew", dev: "New issue" },
66 settings: { studio: "Settings", dev: "Settings" },
67 } as const;
68
69 export type TitledPage = keyof typeof PAGE_TITLES;
70
71 /** A page's `<title>` noun and the repository it is about, in the face
72 * the viewer has: `Everything told · notes`, or `Issues · notes`. */
73 export const pageTitle = (
74 which: TitledPage,
75 repo: Repo,
76 studio: boolean,
77 ): string =>
78 `${PAGE_TITLES[which][studio ? "studio" : "dev"]} · ${repo.name}`;
test/studio-words.test.ts +107 −9
@@ -11,7 +11,10 @@
1111 * Nothing else in this file changes — the same forbidden-vocabulary,
1212 * chrome, header and size assertions walk it, and so does the `?mode=dev`
1313 * test below, which is why a row carries the dev words the same path still
14 * says in dev's own face: the table is the one place a page is added, and
14 * says in dev's own face. A row also names the `<title>` it answers with in
15 * each face — the browser tab, the bookmark, the first thing a screen reader
16 * announces (#262) — since `<head>` is outside `<main>` and nothing else
17 * here would read it. The table is the one place a page is added, and
1518 * the proof it is not vacuous comes with it. Whatever a row needs to be
1619 * worth fetching — a second tell, a second change, a run that stopped, a
1720 * run still going — the one `beforeAll` seeds; there is no second seeding
@@ -132,6 +135,11 @@ const pageContent = (html: string): string =>
132135 // and it rides on things dev and studio share alike (a timeline dot).
133136 .replace(/<svg[\s\S]*?<\/svg>/g, "");
134137
138 /** The page's own title — `page()`'s `<title>`, without the sitewide
139 * ` · forge` every page ends with. */
140 const titleOf = (html: string): string =>
141 (html.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "").replace(/ · forge$/, "");
142
135143 /** Generous next to a real page (tens of KB); nowhere near the 2.5 MB one
136144 * file diff cost the studio pull page before it had its own render. */
137145 const MAX_BODY_BYTES = 300_000;
@@ -155,6 +163,10 @@ interface StudioPage {
155163 /** Dev's own words the same path still says at `?mode=dev`, when dev has
156164 * a page of its own there. */
157165 readonly dev?: readonly string[];
166 /** The whole `<title>` the studio's page answers with, ` · forge` aside. */
167 readonly title?: string;
168 /** The whole `<title>` dev's own page answers with at `?mode=dev`. */
169 readonly devTitle?: string;
158170 }
159171
160172 /** `?mode=dev` added to a path that may already carry a query. */
@@ -491,6 +503,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
491503 // both stay the picture card's and the word badge's alone (#172).
492504 word: "app",
493505 path: repoPath,
506 title: "vocab",
494507 shows: [`href="#i-pull"/></svg>Changes`],
495508 hides: [`href="#i-eye"/></svg>Look`, `>Look<`],
496509 },
@@ -501,6 +514,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
501514 // dev's own noun on the studio's own page.
502515 shows: ["Close tell"],
503516 dev: ["<h2>Labels</h2>", "Close issue"],
517 devTitle: `${tellTitle} · vocab`,
504518 },
505519 // The studio's own nouns for the same two pages (#158): one handler
506520 // each, so what is walked here is the very page above under the
@@ -511,8 +525,14 @@ describe("dev's vocabulary never reaches a studio page", () => {
511525 word: "tell (the studio's noun)",
512526 path: `${repoPath}/tells/${tellNumber}`,
513527 shows: [tellTitle, "Close tell"],
528 title: `${tellTitle} · vocab`,
529 },
530 {
531 word: "working",
532 path: `${repoPath}/runs/${runId}`,
533 title: `scout on #${tellNumber} ${tellTitle} · vocab`,
534 devTitle: `scout on #${tellNumber} ${tellTitle} · vocab`,
514535 },
515 { word: "working", path: `${repoPath}/runs/${runId}` },
516536 {
517537 word: "look",
518538 path: `${repoPath}/pulls/${openPullNumber}`,
@@ -520,11 +540,13 @@ describe("dev's vocabulary never reaches a studio page", () => {
520540 // request" here, dev's own noun on the studio's own page.
521541 shows: ["Close change"],
522542 dev: ["diff-table", openBranch, "Close pull request"],
543 devTitle: `${openPullTitle} · vocab`,
523544 },
524545 {
525546 word: "look (the studio's noun)",
526547 path: `${repoPath}/changes/${openPullNumber}`,
527548 shows: [openPullTitle, "Close change"],
549 title: `${openPullTitle} · vocab`,
528550 },
529551 {
530552 word: "shipped",
@@ -565,6 +587,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
565587 path: `${repoPath}/issues`,
566588 shows: [untouchedTitle, tellTitle],
567589 dev: [`>#${tellNumber} opened`],
590 devTitle: "Issues · vocab",
568591 },
569592 {
570593 word: "tells (told)",
@@ -584,17 +607,37 @@ describe("dev's vocabulary never reaches a studio page", () => {
584607 word: "tells (the studio's noun)",
585608 path: `${repoPath}/tells`,
586609 shows: [untouchedTitle, tellTitle],
610 title: "Everything told · vocab",
611 },
612 {
613 // #262: the one compose page, under the studio's own noun and dev's
614 // own address alike — the tab, the heading and the button all say
615 // the studio's word, and the form still posts where it always has.
616 word: "tell the crew (the studio's noun)",
617 path: `${repoPath}/tells/new`,
618 shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"],
619 title: "Tell the crew · vocab",
620 },
621 {
622 word: "tell the crew",
623 path: `${repoPath}/issues/new`,
624 shows: [`action="${repoPath}/issues/new"`, "<h1 class=\"mt\">Tell the crew</h1>"],
625 title: "Tell the crew · vocab",
626 dev: ["New issue", "Create issue"],
627 devTitle: "New issue · vocab",
587628 },
588629 {
589630 word: "changes (the studio's noun)",
590631 path: `${repoPath}/changes`,
591632 shows: [openPullTitle, secondChangeTitle],
633 title: "Every change · vocab",
592634 },
593635 {
594636 word: "changes",
595637 path: `${repoPath}/pulls`,
596638 shows: [openPullTitle, secondChangeTitle],
597639 dev: [openBranch],
640 devTitle: "Pull requests · vocab",
598641 },
599642 {
600643 word: "changes (look)",
@@ -621,6 +664,8 @@ describe("dev's vocabulary never reaches a studio page", () => {
621664 // out — the filter below is where it shows up.
622665 hides: [`href="${repoPath}/runs/${stoppedRunId}"`],
623666 dev: ["Done"],
667 title: "Every run · vocab",
668 devTitle: "Runs · vocab",
624669 },
625670 {
626671 word: "runs (stopped)",
@@ -641,6 +686,13 @@ describe("dev's vocabulary never reaches a studio page", () => {
641686 hides: [`href="${repoPath}/runs/${workingRunId}"`],
642687 dev: ["Done"],
643688 },
689 {
690 // The studio's own settings: the crew, and how far it may go.
691 word: "crew",
692 path: `${repoPath}/settings`,
693 shows: ["scout"],
694 title: "Settings · vocab",
695 },
644696 ];
645697 });
646698
@@ -658,8 +710,9 @@ describe("dev's vocabulary never reaches a studio page", () => {
658710 });
659711 // A route not yet wired into the router answers 404 — skip it rather
660712 // than fail, so a row for a page still being built (docs/STUDIO.md's
661 // lists, as they land) does not block this file.
662 if (res.status === 404) continue;
713 // lists, as they land) does not block this file. A row that names its
714 // title is a finished page, and never skipped.
715 if (res.status === 404 && p.title === undefined) continue;
663716 expect(res.status).toBe(200);
664717 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
665718 expect(res.headers.get("referrer-policy")).toBe("same-origin");
@@ -678,7 +731,11 @@ describe("dev's vocabulary never reaches a studio page", () => {
678731 // pages, so its presence here is the chrome, proven.
679732 expect(html).toContain("Studio");
680733
681 const visible = pageContent(html);
734 const title = titleOf(html);
735 if (p.title !== undefined)
736 expect(title, `${p.word} (${p.path}) titles its tab`).toBe(p.title);
737 // The tab is read like `<main>`: the same words, the same reasons.
738 const visible = `<title>${title}</title>${pageContent(html)}`;
682739 for (const { pattern, because } of DEV_WORDS) {
683740 expect(
684741 visible,
@@ -763,7 +820,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
763820 test("?mode=dev on the same routes still carries dev's own words — the assertions above are real, not vacuous", async () => {
764821 let proved = 0;
765822 for (const p of pages) {
766 if (p.dev === undefined) continue;
823 if (p.dev === undefined && p.devTitle === undefined) continue;
767824 const res = await fetch(`${base}${asDev(p.path)}`, {
768825 headers: { cookie },
769826 redirect: "manual",
@@ -775,8 +832,14 @@ describe("dev's vocabulary never reaches a studio page", () => {
775832 // Dev's own words, read where the studio's are read: inside `<main>`,
776833 // so the shared client script's own literals can never stand in for a
777834 // page that said nothing.
778 const visible = pageContent(await res.text());
779 for (const word of p.dev) {
835 const html = await res.text();
836 if (p.devTitle !== undefined)
837 expect(
838 titleOf(html),
839 `${p.word} (${asDev(p.path)}) lost dev's own title`,
840 ).toBe(p.devTitle);
841 const visible = pageContent(html);
842 for (const word of p.dev ?? []) {
780843 expect(
781844 visible,
782845 `${p.word} (${asDev(p.path)}) lost dev's word "${word}" — the studio row for this path proves nothing`,
@@ -793,7 +856,42 @@ describe("dev's vocabulary never reaches a studio page", () => {
793856 // Every row that named dev words was actually reached: the 404 skip
794857 // above is for a page still being built, never a way for a row to stop
795858 // proving itself once it exists.
796 expect(proved).toBe(pages.filter((p) => p.dev !== undefined).length);
859 expect(proved).toBe(
860 pages.filter((p) => p.dev !== undefined || p.devTitle !== undefined)
861 .length,
862 );
863 });
864
865 test("a repository that is not an app keeps dev's own titles in the studio mode — the same `studioRecord` rule, not a second one (#262)", async () => {
866 const alice = getUserByName(app.db, "alice")!;
867 const plain = await createRepo(app, alice, {
868 name: "plain",
869 visibility: "private",
870 description: "not an app",
871 });
872 if (!plain.ok) throw new Error(String(plain.error));
873 const titles = new Map([
874 ["/alice/plain/issues", "Issues · plain"],
875 ["/alice/plain/issues/new", "New issue · plain"],
876 ["/alice/plain/pulls", "Pull requests · plain"],
877 ["/alice/plain/runs", "Runs · plain"],
878 ]);
879 for (const [path, title] of titles) {
880 const res = await fetch(`${base}${path}`, {
881 headers: { cookie: `${cookie}; forge_mode=studio` },
882 redirect: "manual",
883 });
884 expect(res.status, path).toBe(200);
885 expect(titleOf(await res.text()), path).toBe(title);
886 }
887 // No studio, no studio door: the compose address under the studio's
888 // noun hands a non-app to dev's own.
889 const door = await fetch(`${base}/alice/plain/tells/new`, {
890 headers: { cookie: `${cookie}; forge_mode=studio` },
891 redirect: "manual",
892 });
893 expect(door.status).toBe(303);
894 expect(door.headers.get("location")).toBe("/alice/plain/issues/new");
797895 });
798896
799897 test("reopening a merged change refuses in the reader's own noun — dev's word never rides the flash on a studio page (#171)", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs