Public: anyone can read this; the crew can change it.
A tell's sentences live in one module, read by the tell action, the terminal, runs and the report screen #5701
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5650-a-tell-s-sentences-live-in-one-module-re → main 2 commits fork 1 thread · 1 unresolved
8 files
changed +90
−16
src/actions/tell_words.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | /** | |
| 2 | * What a tell or a report says when it is empty or does not go (#5650): | |
| 3 | * one place, so the studio's box, the toolbar's report, the run page and | |
| 4 | * the terminal read the same. Plain strings, no imports. | |
| 5 | */ | |
| 6 | ||
| 7 | /** A tell that failed for a reason nobody can be told about. */ | |
| 8 | export const NOT_TOLD_WORDS = "That could not be told."; | |
| 9 | ||
| 10 | /** A tell from a page whose context could not be read. */ | |
| 11 | export const NOT_TOLD_FROM_WORDS = "That page could not be told from."; | |
| 12 | ||
| 13 | /** A tell, reply or report sent with nothing in it. */ | |
| 14 | export const SAY_SOMETHING_WORDS = "Say something first."; | |
| 15 | ||
| 16 | /** A report that did not reach the issue list. */ | |
| 17 | export const NOT_SENT_WORDS = "That could not be sent."; |
src/tui/screens/report.ts +3 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { openIssueThrough } from "../../actions/issues.ts"; |
| 9 | 9 | import { reportTitle } from "../../actions/page-report.ts"; |
| 10 | import { NOT_SENT_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 10 | 11 | import { NOTE_TOO_LONG } from "../../apps/note_words.ts"; |
| 11 | 12 | import { MAX_BODY } from "../../core/issues.ts"; |
| 12 | 13 | import type { Repo } from "../../core/repos.ts"; |
| @@ -32,12 +33,12 @@ export function reportCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: | ||
| 32 | 33 | hint: "enter sends", |
| 33 | 34 | onSubmit: (said) => { |
| 34 | 35 | const text = said.trim(); |
| 35 | if (text === "") return "Say something first."; | |
| 36 | if (text === "") return SAY_SOMETHING_WORDS; | |
| 36 | 37 | if (text.length > MAX_BODY) return NOTE_TOO_LONG; |
| 37 | 38 | return (async () => { |
| 38 | 39 | const opened = await openIssueThrough(f.app, f.user, repo, { title: reportTitle(text), body: text }); |
| 39 | 40 | if (opened.ok) ctx.toast(`Issue #${opened.value.number} opened.`, "success"); |
| 40 | else ctx.toast("That could not be sent.", "danger"); | |
| 41 | else ctx.toast(NOT_SENT_WORDS, "danger"); | |
| 41 | 42 | })(); |
| 42 | 43 | }, |
| 43 | 44 | }), |
src/tui/screens/tell.ts +6 −5
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; |
| 12 | 12 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 13 | import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 13 | 14 | import type { Repo } from "../../core/repos.ts"; |
| 14 | 15 | import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts"; |
| 15 | 16 | import type { Result } from "../../kernel/result.ts"; |
| @@ -30,7 +31,7 @@ export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: | ||
| 30 | 31 | ? UNVERIFIED_WORDS |
| 31 | 32 | : told.error === "body_too_long" |
| 32 | 33 | ? TOO_LONG_WORDS |
| 33 | : "That could not be told.", | |
| 34 | : NOT_TOLD_WORDS, | |
| 34 | 35 | }; |
| 35 | 36 | } |
| 36 | 37 | |
| @@ -50,7 +51,7 @@ export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R | ||
| 50 | 51 | label: name, |
| 51 | 52 | hint: "enter sends", |
| 52 | 53 | onSubmit: (said) => { |
| 53 | if (said === "") return "Say something first."; | |
| 54 | if (said === "") return SAY_SOMETHING_WORDS; | |
| 54 | 55 | return (async () => { |
| 55 | 56 | const answer = tellAnswer(await tell(f.app, f.user, repo, said)); |
| 56 | 57 | ctx.toast(answer.text, answer.tone); |
| @@ -86,7 +87,7 @@ export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R | ||
| 86 | 87 | label: name, |
| 87 | 88 | hint: "enter sends", |
| 88 | 89 | onSubmit: (said) => { |
| 89 | if (said === "") return "Say something first."; | |
| 90 | if (said === "") return SAY_SOMETHING_WORDS; | |
| 90 | 91 | return tellFromPage(f, ctx, repo, said, page, number); |
| 91 | 92 | }, |
| 92 | 93 | }), |
| @@ -112,7 +113,7 @@ export async function tellFromPage( | ||
| 112 | 113 | ): Promise<void> { |
| 113 | 114 | const where = parseContext({ page, ...(pull === undefined ? {} : { pull }) }); |
| 114 | 115 | if (where === null || where === undefined) { |
| 115 | ctx.toast("That page could not be told from.", "danger"); | |
| 116 | ctx.toast(NOT_TOLD_FROM_WORDS, "danger"); | |
| 116 | 117 | return; |
| 117 | 118 | } |
| 118 | 119 | const answer = tellAnswer(await tell(f.app, f.user, repo, `${said}${contextNote(where)}`)); |
| @@ -128,7 +129,7 @@ export function askForSomething(f: Session, ctx: Ctx, parent: () => Screen, repo | ||
| 128 | 129 | label: repo.name, |
| 129 | 130 | hint: "enter sends", |
| 130 | 131 | onSubmit: (said) => { |
| 131 | if (said === "") return "Say something first."; | |
| 132 | if (said === "") return SAY_SOMETHING_WORDS; | |
| 132 | 133 | return (async () => { |
| 133 | 134 | const told = await tell(f.app, f.user, repo, said, 1, "/"); |
| 134 | 135 | const answer = told.ok ? { text: SENT_WORDS, tone: "success" as const } : tellAnswer(told); |
src/web/client.ts +2 −1
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | |
| 13 | 13 | import { STAGE_BADGE_JS } from "./stage-row.ts"; |
| 14 | 14 | import { paletteHelpersJs } from "./palette.ts"; |
| 15 | import { NOT_TOLD_WORDS } from "../actions/tell_words.ts"; | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * A native `<dialog>` opened as a modal that owns its Escape. Shared by the |
| @@ -1661,7 +1662,7 @@ export const shellJs = /* js */ ` | ||
| 1661 | 1662 | if (note) note.textContent = d.ok ? d.flash : d.error; |
| 1662 | 1663 | if (d.ok) { ta.value = ''; window.setTimeout(function () { box.remove(); }, 2500); } |
| 1663 | 1664 | }).catch(function () { |
| 1664 | if (note) note.textContent = 'That could not be told.'; | |
| 1665 | if (note) note.textContent = '${NOT_TOLD_WORDS}'; | |
| 1665 | 1666 | }).then(function () { sending = false; }); |
| 1666 | 1667 | } |
| 1667 | 1668 | form.addEventListener('submit', function (e) { e.preventDefault(); send(); }); |
src/web/pages/runs.ts +2 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * id, so the redirect itself says nothing about what exists. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 14 | 15 | import { readLooks } from "../../checks/looks.ts"; |
| 15 | 16 | import type { Database } from "bun:sqlite"; |
| 16 | 17 | import { |
| @@ -346,7 +347,7 @@ export async function runSteer(ctx: Ctx): Promise<Response> { | ||
| 346 | 347 | tone: "danger", |
| 347 | 348 | message: |
| 348 | 349 | steered.error === "empty" |
| 349 | ? "Say something first." | |
| 350 | ? SAY_SOMETHING_WORDS | |
| 350 | 351 | : steered.error === "not_agent" |
| 351 | 352 | ? "Only an agent's run can be steered." |
| 352 | 353 | : "You need write access to steer a run.", |
src/web/studio/actions.ts +7 −6
| @@ -13,6 +13,7 @@ import { holdPull, releasePull } from "../../actions/holds.ts"; | ||
| 13 | 13 | import { addComment } from "../../actions/issues.ts"; |
| 14 | 14 | import { parseFrom, SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts"; |
| 15 | 15 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 16 | import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 16 | 17 | import { keepThought } from "../../actions/thought.ts"; |
| 17 | 18 | import { DIRECTION_NOTE } from "../../crew/direct.ts"; |
| 18 | 19 | import { mergePull } from "../../actions/pulls.ts"; |
| @@ -58,7 +59,7 @@ async function tellFrom( | ||
| 58 | 59 | if (text === "") |
| 59 | 60 | return flashOrJson(ctx, back, { |
| 60 | 61 | tone: "danger", |
| 61 | message: "Say something first.", | |
| 62 | message: SAY_SOMETHING_WORDS, | |
| 62 | 63 | }); |
| 63 | 64 | const told = await tell(ctx.app, ctx.auth.user, repo, text, 1, from); |
| 64 | 65 | if (!told.ok && told.error === "body_too_long") |
| @@ -74,7 +75,7 @@ async function tellFrom( | ||
| 74 | 75 | ? DIRECTION_NOTE |
| 75 | 76 | : told.error === "unverified" |
| 76 | 77 | ? "Verify your email first." |
| 77 | : "That could not be told.", | |
| 78 | : NOT_TOLD_WORDS, | |
| 78 | 79 | }); |
| 79 | 80 | const { routed } = told.value; |
| 80 | 81 | // A client's "Ask for something" (named by the form, not guessed from `/`): |
| @@ -119,9 +120,9 @@ async function tellFromPage(ctx: Ctx, repo: Repo, form: Form): Promise<Response> | ||
| 119 | 120 | if (where?.pull !== undefined) keep.set("pull", String(where.pull)); |
| 120 | 121 | const back = `${repoBase(repo)}/tell?${keep}`; |
| 121 | 122 | if (where === null || where === undefined) |
| 122 | return flashTo(ctx, back, { tone: "danger", message: "That page could not be told from." }); | |
| 123 | return flashTo(ctx, back, { tone: "danger", message: NOT_TOLD_FROM_WORDS }); | |
| 123 | 124 | const text = field(form, "text").trim(); |
| 124 | if (text === "") return flashTo(ctx, back, { tone: "danger", message: "Say something first." }); | |
| 125 | if (text === "") return flashTo(ctx, back, { tone: "danger", message: SAY_SOMETHING_WORDS }); | |
| 125 | 126 | const told = await tell(ctx.app, ctx.auth.user, repo, `${text}${contextNote(where)}`); |
| 126 | 127 | if (!told.ok) |
| 127 | 128 | return flashTo(ctx, back, { |
| @@ -133,7 +134,7 @@ async function tellFromPage(ctx: Ctx, repo: Repo, form: Form): Promise<Response> | ||
| 133 | 134 | ? "Verify your email first." |
| 134 | 135 | : told.error === "body_too_long" |
| 135 | 136 | ? TOO_LONG_WORDS |
| 136 | : "That could not be told.", | |
| 137 | : NOT_TOLD_WORDS, | |
| 137 | 138 | }); |
| 138 | 139 | const issue = `${repoBase(repo)}/issues/${told.value.issue.number}`; |
| 139 | 140 | return page({ |
| @@ -309,7 +310,7 @@ export async function saySubmit(ctx: Ctx): Promise<Response> { | ||
| 309 | 310 | if (text === "") |
| 310 | 311 | return flashTo(ctx, base, { |
| 311 | 312 | tone: "danger", |
| 312 | message: "Say something first.", | |
| 313 | message: SAY_SOMETHING_WORDS, | |
| 313 | 314 | }); |
| 314 | 315 | const said = addComment(ctx.app, ctx.auth.user, repo, pull, text); |
| 315 | 316 | return flashOrJson(ctx, base, { |
src/web/studio/toolbar.ts +2 −1
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; |
| 19 | 19 | import { COMMENT_EMPTY, COMMENT_NOT_POSTED } from "../../actions/comment_words.ts"; |
| 20 | 20 | import { reportTitle } from "../../actions/page-report.ts"; |
| 21 | import { NOT_SENT_WORDS } from "../../actions/tell_words.ts"; | |
| 21 | 22 | import { NOTE_TOO_LONG } from "../../apps/note_words.ts"; |
| 22 | 23 | import { getAppByRepo } from "../../core/apps.ts"; |
| 23 | 24 | import { getIssue, MAX_BODY } from "../../core/issues.ts"; |
| @@ -318,7 +319,7 @@ export async function toolbarComment(ctx: Ctx): Promise<Response> { | ||
| 318 | 319 | originError(opened.error) ?? |
| 319 | 320 | (opened.error === "body_too_long" |
| 320 | 321 | ? NOTE_TOO_LONG |
| 321 | : "That could not be sent."), | |
| 322 | : NOT_SENT_WORDS), | |
| 322 | 323 | opened.error === "body_too_long" ? 422 : opened.error === "not_found" ? 404 : 403, |
| 323 | 324 | ); |
| 324 | 325 | } |
test/tell-words.test.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * A tell's sentences live in one module (#5650): every door that says one | |
| 3 | * imports it from src/actions/tell_words.ts, so a copy typed back into a | |
| 4 | * page, a screen or the browser's script fails here before it drifts. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { readFileSync } from "node:fs"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { | |
| 10 | NOT_SENT_WORDS, | |
| 11 | NOT_TOLD_FROM_WORDS, | |
| 12 | NOT_TOLD_WORDS, | |
| 13 | SAY_SOMETHING_WORDS, | |
| 14 | } from "../src/actions/tell_words.ts"; | |
| 15 | ||
| 16 | const SENTENCES = { | |
| 17 | NOT_TOLD_WORDS: "That could not be told.", | |
| 18 | NOT_TOLD_FROM_WORDS: "That page could not be told from.", | |
| 19 | SAY_SOMETHING_WORDS: "Say something first.", | |
| 20 | NOT_SENT_WORDS: "That could not be sent.", | |
| 21 | }; | |
| 22 | ||
| 23 | const DOORS = [ | |
| 24 | "src/web/studio/actions.ts", | |
| 25 | "src/tui/screens/tell.ts", | |
| 26 | "src/tui/screens/report.ts", | |
| 27 | "src/web/pages/runs.ts", | |
| 28 | "src/web/studio/toolbar.ts", | |
| 29 | "src/web/client.ts", | |
| 30 | ]; | |
| 31 | ||
| 32 | const read = (path: string): string => readFileSync(join(import.meta.dir, "..", path), "utf8"); | |
| 33 | ||
| 34 | describe("tell words", () => { | |
| 35 | test("the module says each sentence word for word", () => { | |
| 36 | expect({ NOT_TOLD_WORDS, NOT_TOLD_FROM_WORDS, SAY_SOMETHING_WORDS, NOT_SENT_WORDS }).toEqual(SENTENCES); | |
| 37 | }); | |
| 38 | ||
| 39 | for (const door of DOORS) { | |
| 40 | test(`${door} types none of them out`, () => { | |
| 41 | const text = read(door); | |
| 42 | for (const sentence of Object.values(SENTENCES)) expect(text).not.toContain(sentence); | |
| 43 | expect(text).toContain("actions/tell_words.ts"); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | test("the browser's script still says the fallback", async () => { | |
| 48 | const { shellJs } = await import("../src/web/client.ts"); | |
| 49 | expect(shellJs).toContain(`note.textContent = '${NOT_TOLD_WORDS}';`); | |
| 50 | }); | |
| 51 | }); |
new above
offline
Low, not blocking: this drops the constant into a single-quoted JS string. If the sentence ever gets an apostrophe (e.g. "That couldn't be told."), the shell script breaks for everyone.
${JSON.stringify(NOT_TOLD_WORDS)}, with the quotes removed, makes that safe. The guard test'stoContainwould need the double-quoted form then.