web: quote machine output on notices; keep emphasis markers out of hrefs (#530)
Two polish items from live chip use:
- A failed op's notice read as raw provider JSON dumped into the bubble.
Notices gain a `quote` field: prose stays prose ("That didn't work.") and
the raw error renders beneath it as a set-off quotation — rule, muted,
wrapping; deliberately NOT monospaced (it's a quote, not code). Plain
textContent, capped, no links.
- The URL tokenizer swallowed trailing emphasis markers into hrefs, so a
bot's "**github.com/apps/x**" produced a link ending in **. Emphasis
markers join the trailing-punctuation strip. Honest limit, in a comment and
the test: the stars now render as literal text around a clean link — making
them BOLD across a link is beyond this hand-rolled pass (the real Markdown
parser question, tracked separately).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+49
−6
test/format.test.mjs+16 −0web/static/apps.js+10 −1web/static/format.js+7 −2web/static/shell.js+7 −3web/static/style.css+9 −0
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 83301c6..1d04d9e 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -440,5 +440,21 @@ await test("changelogParagraphs handles no marker, blank, and non-string", () => |
| assert.deepEqual(changelogParagraphs(undefined), []); |
| }); |
| +await test("emphasis markers around a URL never ride into the href", () => { |
| + // A bot writing "**github.com/apps/eleven-messenger**" gets a clean link, |
| + // stars left as literal text (bolding ACROSS a link needs the real Markdown |
| + // parser, tracked separately). |
| + const toks = tokenizeText("go to **github.com/apps/eleven-messenger** and install"); |
| + const link = toks.find((t) => t.href); |
| + assert.ok(link, "URL still linkifies next to emphasis markers"); |
| + assert.equal(link.text, "github.com/apps/eleven-messenger"); |
| + assert.ok(!link.href.includes("*"), "no emphasis markers in the href"); |
| + assert.equal(toks.map((t) => t.text).join(""), "go to **github.com/apps/eleven-messenger** and install"); |
| + const toks2 = tokenizeText("see https://docs.github.com/rest/issues#list**"); |
| + const link2 = toks2.find((t) => t.href); |
| + assert.equal(link2.text, "https://docs.github.com/rest/issues#list"); |
| + assert.ok(!link2.href.endsWith("*")); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/apps.js b/web/static/apps.js| index 7a9b1e8..02dd14d 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -1466,13 +1466,22 @@ function fillFullNotice(row, spec = {}, opts = {}) { |
| // A chips-only notice (a persisted bubble's live actions) has no bubble of |
| // its own — the text it belongs to is already in the feed as real history. |
| - if (text || spec.img) { |
| + if (text || spec.img || spec.quote) { |
| const bubble = document.createElement("div"); |
| bubble.className = "local-notice-bubble"; |
| const body = document.createElement("div"); |
| body.className = "local-notice-body"; |
| renderNoticeBody(text || "", body); |
| bubble.appendChild(body); |
| + // Quoted machine output (a provider's error line): set off visually as a |
| + // quotation under the prose — never dumped into the body where it reads as |
| + // the notice's own words. Plain textContent: it's evidence, not content. |
| + if (spec.quote) { |
| + const q = document.createElement("div"); |
| + q.className = "local-notice-quote"; |
| + q.textContent = String(spec.quote).slice(0, 600); |
| + bubble.appendChild(q); |
| + } |
| // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL |
| // (e.g. the setup conversation previewing /api/server-avatar) — never user text. |
| if (spec.img) { |
diff --git a/web/static/format.js b/web/static/format.js| index df2a8d2..0ff6dcd 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -6,8 +6,13 @@ |
| // and abbreviations (node.js, index.html, e.g., main.go) aren't turned into links. |
| const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/gi; |
| -// Trailing punctuation that's almost certainly sentence punctuation, not URL. |
| -const TRAILING = /[.,!?;:'")\]}]+$/; |
| +// Trailing punctuation that's almost certainly sentence punctuation, not URL — |
| +// including emphasis markers: a bot writing "**github.com/apps/x**" must not |
| +// get the closing ** swallowed into the href. (The stars still render as |
| +// literal text rather than bolding across the link — emphasis can't span a |
| +// link token in this hand-rolled pass; the honest fix is the real Markdown |
| +// parser, tracked separately.) |
| +const TRAILING = /[.,!?;:'")\]}*_~`]+$/; |
| const SCHEME_RE = /^https?:\/\//i; |
| const WWW_RE = /^www\./i; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 4e45888..fe12ca8 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -239,9 +239,13 @@ const CHIP_RUNNERS = { |
| service_op: (a) => { |
| const threadId = activeId; |
| requestOp(a.service, a.op, a.args, a.access, 120000, "chip").then((res) => |
| - // On failure the raw error lands here instantly; the bot follows up |
| - // in-thread with a plain-language explanation (the src:"chip" tag). |
| - appendNotice(threadId, { text: res.ok ? summarizeOpResult(a, res) : `Couldn't run that: ${res.error}` }) |
| + // On failure: a plain-words line with the raw error QUOTED under it |
| + // (never dumped into the prose), and the bot follows up in-thread with |
| + // an explanation (the src:"chip" tag). |
| + appendNotice( |
| + threadId, |
| + res.ok ? { text: summarizeOpResult(a, res) } : { text: "That didn't work.", quote: res.error } |
| + ) |
| ); |
| return a.access === "write" ? "Confirmed ✓" : "Requested ✓"; |
| }, |
diff --git a/web/static/style.css b/web/static/style.css| index 631e988..cf0dcf5 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -977,6 +977,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-radius: 5px; |
| } |
| .local-notice-body a { color: var(--accent); } |
| +/* Quoted machine output on a notice (a provider's error): a quotation, set off |
| + with a rule and muted — readable prose framing above, evidence below. Not |
| + monospaced: it's a quote, not code. */ |
| +.local-notice-quote { |
| + margin-top: 8px; padding: 6px 10px; |
| + border-left: 3px solid color-mix(in srgb, var(--muted) 45%, transparent); |
| + font-size: 13px; color: var(--muted); |
| + white-space: pre-wrap; overflow-wrap: anywhere; |
| +} |
| /* Optional preview image on a notice (in-code same-origin URLs only — e.g. the |
| setup conversation previewing the just-uploaded server photo). Fixed box so |
| the feed's scroll position is right BEFORE the image loads. */ |