feat(ui): copy-to-clipboard for messages and rendered blocks

Three idle-only affordances on every chat surface: a persistent copy
button in each assistant bubble's actions bar, a pointer-only floating
button over the hovered markdown block (fence, mermaid diagram, table),
and Enter on a focused block for keyboard users, with the outcome
flashed on the block itself.

Copy resolves to SOURCE, not rendered text.  The renderer stashes each
table's raw markdown in data-md-source at render time — span sentinels
restored in reverse mask order, footnote-definition bodies restored to
raw before their recursive render — and whole-message copy reads the
streaming pipeline's per-frame stash.  The clipboard transport falls
back to the legacy execCommand path for plain-HTTP LAN nodes, cloning
and restoring the user's selection and focus.

Outcomes surface button-local only: flash + title + one live-region
announcement through the shared makeAnnouncer factory (also adopted by
the interactive voice/tool announcers, whose lazily created regions
swallowed their first announcement).  Busy refusals answer with their
own message.  Coordinator retry and admin token-copy keep
zero-module-dependency degrade paths.
This commit is contained in:
Patrick Buckley
2026-08-02 06:00:31 -07:00
parent e526df95d0
commit 729a02a833
14 changed files with 3207 additions and 225 deletions
+311 -11
View File
@@ -95,6 +95,30 @@ Task-agent harness (/taskagent/livepass.html): the task_agent card — a task
success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a
broken card can't screenshot green. broken card can't screenshot green.
Copy harness (/copy/livepass.html): the copy-to-clipboard affordances — the
per-bubble copy button in .msg-actions and the floating block-copy button
over hovered fences / mermaid diagrams / tables (pointer-only; keyboard
copies with Enter on the focused block) — driven through the REAL
InteractivePane (replayHistory plus a live handleEvent stream turn, so the
retry-holder buttons coexist with the persistent copy buttons on the last
bubble; the turn ends idle, matching the affordances' idle-only gate).
navigator.clipboard is stubbed to a recorder, hover/focus/keys are
dispatched synthetically, and every copied payload is compared byte-exact
against the SOURCE (fences, pipes, mermaid text, the bubble's raw
markdown). + &theme=light. document.title stamps
COPY-READY-<bubbles>-<blocks> only when every probe copied exact source;
COPY-FAILED-<reason> otherwise. &kbd=1 probes the KEYBOARD path: focus a
block, dispatch Enter — the block's source lands on the clipboard, the
block carries the outcome flash class, and the floating button stays out
of it — stamps COPY-KBD-READY / COPY-KBD-FAILED-<step>.
Screenshot states: &flash=1 (visual-only
run — no probes; floating button + ✓ state on the fence, holder bar
revealed via focus) and &bare=1 (single hover, no decoration). Known
capture artifact: the DARK-theme &flash=1 shot can omit the floating
button's pixels (headless software compositor; the DOM state is correct
and light theme paints) — judge the dark floating button from &bare=1
and the ✓ state from the light shot. &stepmax=N bisects a paint
regression to the interaction that triggers it.
Perf harness (/perf/livepass.html): long-session performance baseline for the Perf harness (/perf/livepass.html): long-session performance baseline for the
interactive pane — mounts the REAL InteractivePane at real scroll geometry interactive pane — mounts the REAL InteractivePane at real scroll geometry
(fixed-height mount, production CSS chain) and drives production-shaped (fixed-height mount, production CSS chain) and drives production-shaped
@@ -957,7 +981,24 @@ ATTACH_TEMPLATE = """<!doctype html>
# is exercised, not just the leaf builders. The page frame is harness-only # is exercised, not just the leaf builders. The page frame is harness-only
# chrome; the .conv-batch / task_agent card is what's under review. # chrome; the .conv-batch / task_agent card is what's under review.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
TASKAGENT_TEMPLATE = """<!doctype html> # The host seams a mounted InteractivePane provides, stubbed once for every
# harness that drives the REAL pane (taskagent, copy). A new required seam
# gets added HERE — a harness left with a stale stub set does not fail at
# review time, it throws HARNESS ERROR at run time.
PANE_STUB_JS = """\
// Drive the REAL pane; stub only the host seams a mounted pane provides.
const pane = new InteractivePane("demo-ws");
pane.messagesEl = messages;
pane.inputEl = document.createElement("textarea");
pane.sendBtn = document.createElement("button");
pane.isNearBottom = () => false;
pane.scrollToBottom = () => {};
pane.removeEmptyState = () => {};
pane.removeThinkingIndicator = () => {};
pane.setBusy = () => {};"""
TASKAGENT_TEMPLATE = (
"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -1007,16 +1048,9 @@ TASKAGENT_TEMPLATE = """<!doctype html>
const messages = document.getElementById("messages"); const messages = document.getElementById("messages");
try { try {
// Drive the REAL pane; stub only the host seams a mounted pane provides. """
const pane = new InteractivePane("demo-ws"); + PANE_STUB_JS
pane.messagesEl = messages; + """
pane.inputEl = document.createElement("textarea");
pane.sendBtn = document.createElement("button");
pane.isNearBottom = () => false;
pane.scrollToBottom = () => {};
pane.removeEmptyState = () => {};
pane.removeThinkingIndicator = () => {};
pane.setBusy = () => {};
const ev = (e) => pane.handleEvent(e); const ev = (e) => pane.handleEvent(e);
// ?recall=1: exercise the RECALL path — replayHistory rebuilding the // ?recall=1: exercise the RECALL path — replayHistory rebuilding the
@@ -1165,6 +1199,266 @@ TASKAGENT_TEMPLATE = """<!doctype html>
</body> </body>
</html> </html>
""" """
)
# --------------------------------------------------------------------------
# Copy harness — the copy-to-clipboard affordances over the REAL pane. The
# bubbles come from the REAL replayHistory / handleEvent paths so the copy
# sources are the ones production stashes (_copySource, the mermaid / table
# data attributes), and the probes drive the REAL buttons and key path and
# compare what landed on the (stubbed) clipboard byte-exact against the
# source.
# --------------------------------------------------------------------------
COPY_TEMPLATE = (
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>copy livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/chat.css" />
<link rel="stylesheet" href="shared/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<style>
/* Harness-only framing (NOT under review) — a plausible pane context. */
body {
padding: 24px; margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--font-sans, system-ui, sans-serif);
}
.demo-frame { max-width: 720px; margin: 0 auto; }
.demo-label {
font: 11px var(--font-mono, monospace); color: var(--ink-3);
text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 8px;
}
</style>
</head>
<body>
<div class="demo-frame">
<div class="demo-label">conversation — copy affordances (real InteractivePane)</div>
<div class="messages" id="messages"></div>
</div>
<script>
window.toast = { error: function (m) { console.log("toast:", m); } };
window.authFetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve({}); },
text: function () { return Promise.resolve(""); },
});
};
// Deterministic clipboard: record instead of writing. localhost is a
// secure context so copyTextToClipboard takes the async-API branch and
// hits this stub; force isSecureContext for any odd serving setup.
window.__copied = [];
try {
Object.defineProperty(window, "isSecureContext", { value: true });
} catch (e) { /* already true */ }
try {
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: function (t) {
window.__copied.push(t);
return Promise.resolve();
},
},
configurable: true,
});
} catch (e) {
document.title = "COPY-FAILED-clipboard-stub";
}
</script>
<script type="module">
import { InteractivePane } from "./shared/interactive.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
const FENCE_SRC = 'def stash(depth):\\n total = 0\\n for k in range(depth):\\n total += k\\n return total';
const TABLE_SRC = '| node | state |\\n|---|:--:|\\n| flat | idle |\\n| blck | busy |';
const MERMAID_SRC = 'graph TD\\n A --> B\\n B --> C';
const MD_ONE =
'First reply with a fence and a table.\\n\\n' +
'```python\\n' + FENCE_SRC + '\\n```\\n\\n' +
TABLE_SRC + '\\n\\nTrailing prose under the table.';
const MD_TWO =
'Second reply with a diagram.\\n\\n' +
'```mermaid\\n' + MERMAID_SRC + '\\n```\\n\\n' +
'And `inline code` after it.';
const MD_LIVE =
'Streamed reply: the **live** turn, so the retry holder lands here.';
const messages = document.getElementById("messages");
const fail = (r) => { document.title = "COPY-FAILED-" + r; };
try {
"""
+ PANE_STUB_JS
+ """
pane.replayHistory([
{ role: "user", content: "Show me the stash helper and the node table." },
{ role: "assistant", content: MD_ONE },
{ role: "user", content: "Now the flow as a diagram, please." },
{ role: "assistant", content: MD_TWO },
]);
// A live streamed turn on top — the retry holder must land on this
// bubble WITHOUT stripping its (or any) copy button.
pane.handleEvent({ type: "state_change", state: "running" });
for (let k = 0; k < MD_LIVE.length; k += 16)
pane.handleEvent({ type: "content", text: MD_LIVE.slice(k, k + 16) });
pane.handleEvent({ type: "stream_end" });
pane.handleEvent({ type: "state_change", state: "idle" });
const hover = (el) =>
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
const fabEl = () => document.querySelector(".block-copy-btn");
// Let the streamed bubble's rAF render + retry attach settle.
setTimeout(async () => {
try {
const bubbles = messages.querySelectorAll(".msg.assistant");
const bars = messages.querySelectorAll(
".msg.assistant .msg-actions .msg-copy-btn",
);
if (bubbles.length !== 3) return fail("bubbles" + bubbles.length);
if (bars.length !== 3) return fail("bars" + bars.length);
const last = bubbles[bubbles.length - 1];
if (!last.querySelector(".msg-retry-btn"))
return fail("no-retry-on-holder");
if (!last.querySelector(".msg-copy-btn"))
return fail("holder-lost-copy");
// Block probes: hover reveals the floating button; a click must
// land the byte-exact SOURCE on the clipboard.
const probes = [
[messages.querySelector(".msg.assistant pre"), FENCE_SRC, "fence"],
[messages.querySelector(".table-wrap"), TABLE_SRC, "table"],
[messages.querySelector(".mermaid-container"), MERMAID_SRC, "mermaid"],
];
// &bare=1 — diagnostic state: no probe clicks, no repositioning;
// one hover on the fence and stop. Splits "the probe cycle
// corrupts the button's paint" from "it never paints here".
if (q.get("bare") === "1") {
hover(probes[0][0]);
document.title = "COPY-BARE";
return;
}
// &kbd=1 — the keyboard path: Enter on a FOCUSED block copies
// that block's source directly. Blocks are focusable (tabindex=0
// from the fence / table / mermaid renders), the outcome flashes
// on the block itself, and the floating button — pointer-only —
// must stay out of it entirely (never created, never revealed).
if (q.get("kbd") === "1") {
const tw = messages.querySelector(".table-wrap");
if (!tw) return fail("kbd-no-block");
tw.focus();
const focused = document.activeElement === tw;
tw.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
await new Promise((r) => setTimeout(r, 0));
const copied =
window.__copied[window.__copied.length - 1] === TABLE_SRC;
const flashed = tw.classList.contains("is-copied");
const fabStaysOut =
!fabEl() || !fabEl().classList.contains("is-visible");
document.title =
focused && copied && flashed && fabStaysOut
? "COPY-KBD-READY"
: "COPY-KBD-FAILED-" +
[
focused ? "" : "focus",
copied ? "" : "copy",
flashed ? "" : "flash",
fabStaysOut ? "" : "fab",
]
.filter(Boolean)
.join("-");
return;
}
// &flash=1 — the VISUAL state, screenshot-only: skip the probes so
// the fence hover is the floating button's FIRST show. Returning
// the button to an already-visited position stops it PAINTING in
// headless captures (visible + hit-testable, no pixels — a stale
// compositor tile; bisected via &stepmax). Function and pixels
// are therefore split: the probe run (no flash) is the verdict,
// this state is the picture.
if (q.get("flash") === "1") {
bars[bars.length - 1].focus();
hover(probes[0][0]);
const fab = fabEl();
if (!fab) return fail("no-fab-visual");
fab.classList.add("is-copied");
fab.title = "Copied";
// Freeze: the capture pipeline synthesizes a pointer event
// outside the block at screenshot time, which would hide the
// button (correct in production). Capture-phase stops starve
// the module's delegated listeners for the capture.
for (const t of ["mouseover", "scroll"])
document.addEventListener(t, (e) => e.stopPropagation(), true);
document.title = "COPY-VISUAL";
return;
}
// &stepmax=N — diagnostic: stop after the Nth interaction (hovers
// and clicks count) and stamp COPY-STEP-N, so a paint regression
// can be bisected to the interaction that triggers it.
let step = 0;
const stepMax = parseInt(q.get("stepmax") || "999", 10);
const gate = () => {
step += 1;
if (step > stepMax) {
document.title = "COPY-STEP-" + (step - 1);
throw { __stop: true };
}
};
let done = 0;
for (const [el, want, name] of probes) {
if (!el) return fail("no-" + name);
gate();
hover(el);
const fab = fabEl();
if (!fab || !fab.classList.contains("is-visible"))
return fail("fab-hidden-" + name);
gate();
fab.click();
await new Promise((r) => setTimeout(r, 0));
const got = window.__copied[window.__copied.length - 1];
if (got !== want) {
console.log("copy mismatch", name, JSON.stringify(got));
return fail("source-" + name);
}
done += 1;
}
// Bubble probe: the whole raw markdown, fences and pipes intact.
gate();
bars[0].click();
await new Promise((r) => setTimeout(r, 0));
if (window.__copied[window.__copied.length - 1] !== MD_ONE)
return fail("bubble-source");
document.title = "COPY-READY-" + bars.length + "-" + done;
} catch (e) {
if (!(e && e.__stop)) {
console.log("copy harness error", e);
fail("error");
}
}
}, 400);
} catch (e) {
messages.textContent = "HARNESS ERROR: " + e.message;
fail("error");
}
</script>
</body>
</html>
"""
)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -1612,6 +1906,12 @@ def build(out: Path) -> None:
(ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8") (ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8")
print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)") print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)")
cp = out / "copy"
cp.mkdir(parents=True, exist_ok=True)
symlink(cp / "shared", ROOT / "turnstone/shared_static")
(cp / "livepass.html").write_text(COPY_TEMPLATE, encoding="utf-8")
print(f"{cp}/livepass.html — copy affordances (bubble bars + block button)")
pf = out / "perf" pf = out / "perf"
pf.mkdir(parents=True, exist_ok=True) pf.mkdir(parents=True, exist_ok=True)
symlink(pf / "shared", ROOT / "turnstone/shared_static") symlink(pf / "shared", ROOT / "turnstone/shared_static")
+45
View File
@@ -0,0 +1,45 @@
"""Shared helpers for the Python-driven node harnesses that evaluate the
``shared_static`` ES modules with script semantics."""
from __future__ import annotations
import re
import shutil
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
def has_node() -> bool:
return shutil.which("node") is not None
# Module-level ``pytestmark = node_skip`` in each harness suite — the node
# detection lives here once, so a future change (version floor, env
# override) cannot land in one suite and silently miss another.
node_skip = pytest.mark.skipif(not has_node(), reason="node not available")
def demodulize(path: Path) -> str:
"""Strip ES-module syntax so ``vm.runInThisContext`` (script semantics)
can evaluate the file: imports drop (the harness loads the whole
dependency set into one shared context, so cross-file bindings resolve
as context globals, exactly like the pre-module classic scripts), and
``export`` keywords peel off their declarations.
Single-sourced here for every JS harness: a new module syntax form
(``export default``, re-exports) must be handled once, not per suite —
a divergence between per-file copies surfaces as a confusing
``vm.runInThisContext`` SyntaxError in whichever suite lagged.
"""
src = path.read_text(encoding="utf-8")
src = re.sub(r"^import\s+\{[\s\S]*?\}\s+from\s+\"[^\"]+\";\s*$", "", src, flags=re.M)
src = re.sub(r"^import\s+[^;\n]+;\s*$", "", src, flags=re.M)
src = re.sub(
r"^export\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)", "", src, flags=re.M
)
src = re.sub(r"^export\s*\{[^}]*\};\s*$", "", src, flags=re.M)
return src
+255 -15
View File
@@ -626,8 +626,8 @@ def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None:
1. **Strict DOM-construction** (``ui/static/app.js``, 1. **Strict DOM-construction** (``ui/static/app.js``,
``shared_static/utils.js``, ``shared_static/auth.js``, ``shared_static/utils.js``, ``shared_static/auth.js``,
``shared_static/kb.js``, ``coordinator.js`` chat entry, ``shared_static/kb.js``, ``coordinator.js`` chat entry,
``console/static/app.js``): renderer output routes through ``console/static/app.js``): renderer output routes through the
``setMarkdown`` (or ``setSafeHtml`` for pre-baked HTML strings); streaming helpers or ``setSafeHtml`` (for pre-baked HTML strings);
every other site uses ``createElement`` + ``textContent`` + every other site uses ``createElement`` + ``textContent`` +
``append`` / ``replaceChildren``. Missing escapes are ``append`` / ``replaceChildren``. Missing escapes are
structurally impossible — no HTML string is ever interpolated. structurally impossible — no HTML string is ever interpolated.
@@ -666,8 +666,8 @@ def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None:
f"{label}:\n" f"{label}:\n"
+ "\n".join(f" line {n}: {line}" for n, line in offenders[:10]) + "\n".join(f" line {n}: {line}" for n, line in offenders[:10])
+ "\nUse DOM construction (createElement + textContent + " + "\nUse DOM construction (createElement + textContent + "
"append/replaceChildren) or route renderer output through " "append/replaceChildren) or route trusted HTML through "
"setMarkdown() / setSafeHtml() in shared/utils.js." "setSafeHtml() in shared/utils.js."
) )
@@ -784,18 +784,20 @@ def test_model_response_controls_are_capability_driven_and_sparse() -> None:
assert 'apiSurfEl.addEventListener("change", _onModelFieldChange)' in admin assert 'apiSurfEl.addEventListener("change", _onModelFieldChange)' in admin
def test_shared_utils_defines_set_markdown_helper() -> None: def test_shared_utils_defines_set_safe_html_helper() -> None:
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single """``setSafeHtml`` in ``shared/utils.js`` is the single audited entry
audited entry point for rendering markdown content into a DOM point for installing trusted HTML strings into a DOM element outside
element from ``app.js``. It parses ``renderMarkdown``'s output via renderer.js. It parses via ``DOMParser`` (avoiding the unsafe sink
``DOMParser`` (avoiding the unsafe sink entirely) and runs entirely); its markdown callers use it directly (coordinator
``postRenderMarkdown`` on the result. A refactor that drops or ``appendMsg``, preview.js), while renderer.js's streaming helpers
renames it would break the two interactive call sites silently at write their own sanctioned in-file ``innerHTML`` — which is exactly
why renderer.js is excluded from the sink scan. A refactor that
drops or renames the helper would break its call sites silently at
runtime.""" runtime."""
body = _UTILS_JS.read_text(encoding="utf-8") body = _UTILS_JS.read_text(encoding="utf-8")
assert "function setMarkdown(el, content)" in body, ( assert "function setSafeHtml(el, html)" in body, (
"shared/utils.js must define setMarkdown(el, content) — " "shared/utils.js must define setSafeHtml(el, html) — the "
"app.js routes both renderer-output sites through this helper." "sanctioned trusted-HTML installation chokepoint."
) )
# The DOMParser path is what avoids the unsafe sink. The absence # The DOMParser path is what avoids the unsafe sink. The absence
# of the unsafe assignment inside the helper is pinned by the # of the unsafe assignment inside the helper is pinned by the
@@ -804,7 +806,7 @@ def test_shared_utils_defines_set_markdown_helper() -> None:
# to e.g. ``Range.createContextualFragment`` forces an explicit # to e.g. ``Range.createContextualFragment`` forces an explicit
# reviewer decision. # reviewer decision.
assert "DOMParser()" in body, ( assert "DOMParser()" in body, (
"setMarkdown must parse via DOMParser, not the unsafe DOM-write " "setSafeHtml must parse via DOMParser, not the unsafe DOM-write "
"sink — that is what keeps the audit surface at one location." "sink — that is what keeps the audit surface at one location."
) )
@@ -3422,3 +3424,241 @@ def test_every_system_turn_source_has_a_fallback_label() -> None:
labelled = {ln.split(":", 1)[0].strip() for ln in block.splitlines() if ":" in ln} labelled = {ln.split(":", 1)[0].strip() for ln in block.splitlines() if ":" in ln}
missing = set(SYSTEM_TURN_SOURCES) - labelled - {"compaction"} missing = set(SYSTEM_TURN_SOURCES) - labelled - {"compaction"}
assert not missing, f"system turn sources with no operator label: {sorted(missing)}" assert not missing, f"system turn sources with no operator label: {sorted(missing)}"
def test_copy_button_survives_retry_teardown_in_both_clients() -> None:
"""Every assistant bubble carries a persistent copy button in its
``.msg-actions`` bar; the retry-holder teardown in BOTH clients must
remove only the transient buttons (``.msg-retry-btn`` /
``.msg-tts-btn``), never the bar — removing the bar (the pre-copy
behavior) silently strips copy from the previous holder bubble on
every busy→idle edge, and only manual testing would notice.
Interactive: the tracked-holder teardown targets the button classes.
Coordinator: ``_refreshRetryButton``'s sweep collects
``.msg-retry-btn`` nodes, not whole ``.msg-actions`` bars."""
interactive = _INTERACTIVE_JS.read_text(encoding="utf-8")
start = _pane_method_offset(interactive, "_attachRetryToLastAssistant")
end = _pane_method_offset(interactive, "announceToolBlock")
teardown = interactive[start:end]
assert '".msg-retry-btn, .msg-tts-btn"' in teardown, (
"interactive teardown must remove the transient retry/TTS buttons by class"
)
assert "oldBar.remove()" not in teardown, (
"interactive teardown removes the whole .msg-actions bar — that "
"strips the persistent copy button from the previous holder bubble"
)
coord = _COORD_JS.read_text(encoding="utf-8")
refresh_start = coord.index("function _refreshRetryButton()")
refresh_end = coord.index("async function init()", refresh_start)
refresh = coord[refresh_start:refresh_end]
assert '".msg.assistant .msg-retry-btn"' in refresh, (
"coordinator _refreshRetryButton must sweep retry BUTTONS"
)
assert '".msg.assistant .msg-actions"' not in refresh, (
"coordinator _refreshRetryButton sweeps whole .msg-actions bars — "
"that strips every bubble's persistent copy button"
)
def test_every_assistant_bubble_creation_path_attaches_copy() -> None:
"""The copy button attaches at bubble CREATION on every assistant
render path — both live-stream branches and the history replay in the
interactive pane, and the single ``appendMsg`` chokepoint in the
coordinator (guarded on the literal role, so the unknown-role
fallback variant doesn't grow one). A path that skips the attach
ships bubbles that silently cannot be copied."""
interactive = _INTERACTIVE_JS.read_text(encoding="utf-8")
# Structural call statements only (line-anchored), so a comment or
# string mentioning the method cannot satisfy or break the count.
# The two live-stream branches share one creation helper; the replay
# branch attaches on its own bubble.
stream_calls = len(re.findall(r"^\s+this\._newAssistantBubble\(", interactive, re.M))
assert stream_calls == 2, (
"expected the content + in_progress_snapshot branches to create "
f"bubbles via this._newAssistantBubble(); found {stream_calls} call sites"
)
calls = len(re.findall(r"^\s+this\._addCopyAction\(", interactive, re.M))
assert calls == 2, (
"expected the shared creation helper + the replay branch to call "
f"this._addCopyAction(…); found {calls} call sites"
)
replay_start = _pane_method_offset(interactive, "replayHistory")
replay_end = _pane_method_offset(interactive, "_attachRetryToLastAssistant")
replay = interactive[replay_start:replay_end]
assert "streamingRenderFinalize(bodyEl, msg.content)" in replay, (
"replayed assistant bubbles must render through "
"streamingRenderFinalize — the finalize helper is what stashes "
"the raw markdown the copy affordance reads"
)
coord = _COORD_JS.read_text(encoding="utf-8")
append_start = coord.index("function appendMsg(role, html, opts)")
append_end = coord.index("function appendText(", append_start)
append = coord[append_start:append_end]
assert 'role === "assistant"' in append and "buildMsgCopyButton(el)" in append, (
"coordinator appendMsg must attach the copy bar for assistant bubbles at creation"
)
def test_block_copy_btn_css_box_matches_js_constants() -> None:
"""The floating copy button's placement clamps (copy_actions.js FAB_W /
FAB_H) mirror the CSS box (.block-copy-btn width/height in shared
chat.css). The CSS is the source of truth; this pin is what makes a
CSS resize fail loudly instead of silently desyncing every clamp —
placement itself has no unit assertions (the livepass harness owns
rendered placement)."""
ca = (_REPO_ROOT / "turnstone/shared_static/copy_actions.js").read_text(encoding="utf-8")
css = (_REPO_ROOT / "turnstone/shared_static/chat.css").read_text(encoding="utf-8")
w = re.search(r"const FAB_W = (\d+);", ca)
h = re.search(r"const FAB_H = (\d+);", ca)
assert w and h, "FAB_W / FAB_H constants not found in copy_actions.js"
# The box must be declared in exactly ONE .block-copy-btn rule: a
# second declaration (a media query, a state variant) would override
# the box while this pin kept watching the base rule — silent desync.
rules = re.findall(r"\.block-copy-btn[^{}]*\{[^}]*\}", css)
assert rules, ".block-copy-btn rules not found in chat.css"
declaring = [r for r in rules if re.search(r"\b(width|height)\s*:", r)]
assert len(declaring) == 1, (
".block-copy-btn width/height must live in exactly one rule so the "
"JS mirror has one source of truth; found: " + repr(declaring)
)
block = declaring[0]
assert f"width: {w.group(1)}px" in block, (
".block-copy-btn width in chat.css no longer matches FAB_W — "
"update the JS mirror (and its clamps) with the CSS box"
)
assert f"height: {h.group(1)}px" in block, (
".block-copy-btn height in chat.css no longer matches FAB_H — "
"update the JS mirror (and its clamps) with the CSS box"
)
def test_retry_and_token_copy_degrade_without_the_module_lane() -> None:
"""Two classic-script affordances predate the shared ES-module lane and
must keep working when it fails to load (script fetch error on a
degraded LAN): the coordinator's retry button and the admin one-time
token dialog's copy. Both prefer the bridged helpers and must carry a
zero-dependency fallback — a silent early-return (retry never renders)
or an unguarded bridge call (uncaught TypeError in the token modal)
regresses main's contract."""
coord = _COORD_JS.read_text(encoding="utf-8")
retry_start = coord.index("function _addRetryAction(el)")
retry_end = coord.index("function _refreshRetryButton()", retry_start)
retry = coord[retry_start:retry_end]
assert 'typeof ensureMsgActionsBar === "function"' in retry, (
"coordinator retry must feature-detect the bridged helpers"
)
assert 'document.createElement("button")' in retry and "msg-retry-btn" in retry, (
"coordinator retry lost its zero-dependency fallback — with the "
"module lane down the retry button must still be built in place"
)
admin = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8")
copy_start = admin.index("function copyCreatedToken()")
copy_fn = admin[copy_start : admin.index("\nfunction ", copy_start + 1)]
assert 'typeof window.copyTextToClipboard !== "function"' in copy_fn, (
"admin token copy must feature-detect the bridged helper — an "
"unguarded bridge call throws with the module lane down"
)
# The no-bridge branch is not selection-only: it attempts the native
# async API directly (the degraded page is usually an HTTPS console
# where it works) before degrading to select-the-text.
no_bridge = copy_fn[
copy_fn.index('typeof window.copyTextToClipboard !== "function"') : copy_fn.index(
"window.copyTextToClipboard(_lastCreatedToken)"
)
]
assert "navigator.clipboard" in no_bridge and ".writeText(_lastCreatedToken)" in no_bridge, (
"the no-bridge branch must attempt navigator.clipboard.writeText "
"directly before falling back to manual selection"
)
assert copy_fn.count("selectTokenFallback") >= 3, (
"admin token copy lost its select-the-text fallback (needed for "
"the clipboard-less, the copy-rejected and the bridge-failed paths)"
)
def test_copy_affordances_are_idle_only() -> None:
"""Every copy affordance is unavailable while the pane is busy, gated on
the ONE pane-level fact both clients already maintain (data-busy on
the messages container) — there is no per-bubble streaming stamp.
Three pins keep the contract honest:
* no ``.is-streaming`` mechanism anywhere in either client or the
shared stylesheet (its per-bubble gate was replaced wholesale);
* the chat.css busy rule covers ALL action buttons — no copy
exemption;
* the module enforces the gate in JS at each activation path (bubble
click, floating-button click, Enter keydown, hover reveal, and the
within-block fast path's dismissal) — CSS pointer-events alone is
keyboard-bypassable."""
interactive = _INTERACTIVE_JS.read_text(encoding="utf-8")
coord = _COORD_JS.read_text(encoding="utf-8")
css = (_REPO_ROOT / "turnstone/shared_static/chat.css").read_text(encoding="utf-8")
for label, body in (
("interactive.js", interactive),
("coordinator.js", coord),
("chat.css", css),
):
assert "is-streaming" not in body, (
f"{label} resurrects the per-bubble is-streaming gate — the busy "
"gate is pane-level data-busy only"
)
assert '[data-busy="true"] .msg-action-btn {' in css, (
"chat.css must disable every .msg-action-btn while busy"
)
assert ":not(.msg-copy-btn)" not in css, (
"the busy rule must not exempt the copy button — copy is idle-only"
)
ca = (_REPO_ROOT / "turnstone/shared_static/copy_actions.js").read_text(encoding="utf-8")
assert "'[data-busy=\"true\"]'" in ca, (
"copy_actions.js lost its JS-side busy gate (_isBusy) — the CSS "
"pointer-events rule alone is keyboard-bypassable"
)
for path_marker in (
# bubble copy click
"if (_isBusy(msgEl)) {",
# floating-button click guard
"if (_isBusy(target)) {",
# Enter-on-block keydown
"if (_isBusy(block)) {",
# hover reveal
"&& !_isBusy(block)",
# within-block fast path — a turn starting under a shown button
# must dismiss on the next move, not once the pointer leaves
"if (_isBusy(_fabTarget)) _hideFab();",
):
assert path_marker in ca, (
f"copy_actions.js busy gate missing at an activation path: {path_marker!r}"
)
def test_coordinator_tool_output_pres_are_focusable() -> None:
"""Any pre inside a .msg-body hosts the pointer copy affordance, and
the keyboard copy path acts on the FOCUSED block — so every <pre> the
coordinator's tool-output formatter emits must carry tabindex, or the
same content a pointer user copies in one click is unreachable by
keyboard. Pinned on every <pre> exit of renderToolOutput so a new
exit cannot ship pointer-only."""
coord = _COORD_JS.read_text(encoding="utf-8")
start = coord.index("function renderToolOutput(")
fn = coord[start : coord.index("\n }", start)]
pres = re.findall(r"<pre[^>]*>", fn)
assert pres, "renderToolOutput no longer emits <pre> blocks"
assert all('tabindex="0"' in p for p in pres), (
"a renderToolOutput <pre> exit lost its tabindex — pointer-copyable "
"but keyboard-unreachable:\n" + "\n".join(pres)
)
# The MCP-error card's raw-payload pre reaches .msg-body through the
# coordinator's orphan-result path — same invariant, shared module.
mcp = _MCP_ERROR_JS.read_text(encoding="utf-8")
assert 'pre.setAttribute("tabindex", "0")' in mcp, (
"the MCP-error card's raw-payload pre must stay focusable — it is "
"pointer-copyable wherever the card mounts inside a .msg-body"
)
# And an emptied action bar may not linger as a zero-control toolbar
# landmark on the coordinator's degraded (no-bridge) lane, where the
# fallback bar holds ONLY the transient retry button.
assert "if (!bar.children.length) bar.remove();" in coord, (
"the coordinator retry sweep must remove a bar emptied of its last "
"control — screen readers announce empty 'Message actions' toolbars"
)
File diff suppressed because it is too large Load Diff
+159 -42
View File
@@ -12,7 +12,7 @@ the rendered HTML for a sample input. The assertions check the
resulting markup contains the expected ``<span class="katex">…</span>`` resulting markup contains the expected ``<span class="katex">…</span>``
placeholder and not the raw delimiter. placeholder and not the raw delimiter.
Both files are ES modules now; ``_demodulize`` strips the module syntax Both files are ES modules now; ``demodulize`` strips the module syntax
so the script-semantics harness keeps working. That is DELIBERATE, not so the script-semantics harness keeps working. That is DELIBERATE, not
a shortcut: the mermaid harness pokes renderer-internal state a shortcut: the mermaid harness pokes renderer-internal state
(``_mermaidState``) that script evaluation exposes as a context global (``_mermaidState``) that script evaluation exposes as a context global
@@ -25,39 +25,20 @@ from __future__ import annotations
import json import json
import re import re
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from tests._js_harness_helpers import demodulize, node_skip
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
_UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js" _UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js"
_RENDERER_JS = _REPO_ROOT / "turnstone/shared_static/renderer.js" _RENDERER_JS = _REPO_ROOT / "turnstone/shared_static/renderer.js"
def _has_node() -> bool: pytestmark = node_skip
return shutil.which("node") is not None
pytestmark = pytest.mark.skipif(not _has_node(), reason="node not available")
def _demodulize(path: Path) -> str:
"""Strip ES-module syntax so ``vm.runInThisContext`` (script semantics)
can evaluate the file: imports drop (the harness loads the whole
dependency set into one shared context, so cross-file bindings resolve
as context globals, exactly like the pre-module classic scripts), and
``export`` keywords peel off their declarations."""
src = path.read_text(encoding="utf-8")
src = re.sub(r"^import\s+\{[\s\S]*?\}\s+from\s+\"[^\"]+\";\s*$", "", src, flags=re.M)
src = re.sub(r"^import\s+[^;\n]+;\s*$", "", src, flags=re.M)
src = re.sub(
r"^export\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)", "", src, flags=re.M
)
src = re.sub(r"^export\s*\{[^}]*\};\s*$", "", src, flags=re.M)
return src
_HARNESS_TEMPLATE = """ _HARNESS_TEMPLATE = """
@@ -93,8 +74,8 @@ process.stdout.write(renderMarkdown(input));
def _render(markdown: str) -> str: def _render(markdown: str) -> str:
"""Render ``markdown`` through renderer.js + return the HTML.""" """Render ``markdown`` through renderer.js + return the HTML."""
harness = _HARNESS_TEMPLATE % { harness = _HARNESS_TEMPLATE % {
"utils_src": json.dumps(_demodulize(_UTILS_JS)), "utils_src": json.dumps(demodulize(_UTILS_JS)),
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)), "renderer_src": json.dumps(demodulize(_RENDERER_JS)),
"input": json.dumps(markdown), "input": json.dumps(markdown),
} }
result = subprocess.run( result = subprocess.run(
@@ -206,7 +187,7 @@ def test_latex_math_inside_inline_code_preserved() -> None:
def test_latex_math_inside_fenced_code_preserved() -> None: def test_latex_math_inside_fenced_code_preserved() -> None:
r"""\(...\) inside a fenced block must stay literal.""" r"""\(...\) inside a fenced block must stay literal."""
out = _render("```\nA \\(x\\) sample\n```") out = _render("```\nA \\(x\\) sample\n```")
assert "<pre><code>" in out assert '<pre tabindex="0"><code>' in out
assert '<span class="katex">' not in out assert '<span class="katex">' not in out
@@ -470,8 +451,8 @@ _mermaidState = 'ready';
def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]: def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]:
"""Run a JS snippet against the mermaid-aware harness, return JSON output.""" """Run a JS snippet against the mermaid-aware harness, return JSON output."""
harness = _MERMAID_HARNESS_TEMPLATE % { harness = _MERMAID_HARNESS_TEMPLATE % {
"utils_src": json.dumps(_demodulize(_UTILS_JS)), "utils_src": json.dumps(demodulize(_UTILS_JS)),
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)), "renderer_src": json.dumps(demodulize(_RENDERER_JS)),
"scenario": scenario_js, "scenario": scenario_js,
} }
result = subprocess.run( result = subprocess.run(
@@ -1577,7 +1558,7 @@ def test_forged_code_block_sentinel_does_not_duplicate_block() -> None:
forgery: exactly one code block, no leaked sentinel.""" forgery: exactly one code block, no leaked sentinel."""
md = "```python\nprint('hi')\n```\n\nprose " + _NUL + "CB0" + _NUL + " end" md = "```python\nprint('hi')\n```\n\nprose " + _NUL + "CB0" + _NUL + " end"
out = _render(md) out = _render(md)
assert out.count("<pre>") == 1, "forged CB sentinel duplicated the block:\n" + out assert out.count("<pre") == 1, "forged CB sentinel duplicated the block:\n" + out
assert out.count("print(") == 1 assert out.count("print(") == 1
assert _NUL not in out, "raw NUL / forged sentinel leaked into output" assert _NUL not in out, "raw NUL / forged sentinel leaked into output"
@@ -1597,7 +1578,7 @@ def test_control_strip_preserves_legit_fence_and_inline() -> None:
only removes caller-supplied control chars, which are never valid data).""" only removes caller-supplied control chars, which are never valid data)."""
out = _render("Here is `inline` and a block:\n\n```py\nx = 1\n```") out = _render("Here is `inline` and a block:\n\n```py\nx = 1\n```")
assert "<code>inline</code>" in out assert "<code>inline</code>" in out
assert "<pre><code" in out assert '<pre tabindex="0"><code' in out
assert "x = 1" in out assert "x = 1" in out
assert _NUL not in out assert _NUL not in out
@@ -1671,9 +1652,9 @@ def test_standalone_code_block_not_wrapped_in_paragraph() -> None:
``<p><pre>…</pre></p>``, which a real browser splits into a stray empty ``<p><pre>…</pre></p>``, which a real browser splits into a stray empty
``<p>`` before the ``<pre>``. The unwrap removes the wrapping paragraph.""" ``<p>`` before the ``<pre>``. The unwrap removes the wrapping paragraph."""
out = _render("```py\nx = 1\n```") out = _render("```py\nx = 1\n```")
assert "<pre><code" in out assert '<pre tabindex="0"><code' in out
assert "<p><pre>" not in out, "code block still wrapped in a paragraph:\n" + out assert "<p><pre>" not in out, "code block still wrapped in a paragraph:\n" + out
assert out.strip().startswith("<pre>"), "code block should not be paragraph-wrapped:\n" + out assert out.strip().startswith("<pre"), "code block should not be paragraph-wrapped:\n" + out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1691,7 +1672,7 @@ def test_blockquote_inside_fence_not_extracted() -> None:
fence pass now runs first and masks the region.""" fence pass now runs first and masks the region."""
out = _render("```text\nplain\n> quoted\nafter\n```") out = _render("```text\nplain\n> quoted\nafter\n```")
assert "<blockquote>" not in out, "blockquote extracted from inside a fence:\n" + out assert "<blockquote>" not in out, "blockquote extracted from inside a fence:\n" + out
assert "<pre><code" in out assert '<pre tabindex="0"><code' in out
assert "&gt; quoted" in out, "the quoted line must stay literal (escaped) code:\n" + out assert "&gt; quoted" in out, "the quoted line must stay literal (escaped) code:\n" + out
@@ -1704,7 +1685,9 @@ def test_blockquoted_fence_renders_as_code() -> None:
would have swallowed the blockquoted fence as ``undefined``.)""" would have swallowed the blockquoted fence as ``undefined``.)"""
out = _render("> ```\n> code\n> ```") out = _render("> ```\n> code\n> ```")
assert "<blockquote>" in out assert "<blockquote>" in out
assert "<pre><code>code</code></pre>" in out, "blockquoted fence lost its code:\n" + out assert '<pre tabindex="0"><code>code</code></pre>' in out, (
"blockquoted fence lost its code:\n" + out
)
assert "undefined" not in out assert "undefined" not in out
assert _NUL not in out assert _NUL not in out
@@ -1715,7 +1698,9 @@ def test_indented_fence_still_renders_as_code() -> None:
paragraph of literal backticks. (A bare ``^`` anchor would drop it; the paragraph of literal backticks. (A bare ``^`` anchor would drop it; the
deeper 4-space-indent case is pinned separately.)""" deeper 4-space-indent case is pinned separately.)"""
out = _render(" ```py\n x = 1\n ```") out = _render(" ```py\n x = 1\n ```")
assert "<pre><code" in out, "indented fence dropped (not rendered as code):\n" + out assert '<pre tabindex="0"><code' in out, (
"indented fence dropped (not rendered as code):\n" + out
)
assert "x = 1" in out assert "x = 1" in out
@@ -1772,7 +1757,7 @@ def test_details_inside_fence_stays_literal() -> None:
fence is masked by the (earlier) fence pass and must stay literal escaped fence is masked by the (earlier) fence pass and must stay literal escaped
code, never extracted into a real element.""" code, never extracted into a real element."""
out = _render("```html\n<details><summary>s</summary>x</details>\n```") out = _render("```html\n<details><summary>s</summary>x</details>\n```")
assert "<pre><code" in out assert '<pre tabindex="0"><code' in out
assert "&lt;details&gt;" in out, "details-in-fence should be literal code:\n" + out assert "&lt;details&gt;" in out, "details-in-fence should be literal code:\n" + out
assert "<details>" not in out, "details inside a fence was wrongly extracted:\n" + out assert "<details>" not in out, "details inside a fence was wrongly extracted:\n" + out
@@ -1795,7 +1780,7 @@ def test_code_block_in_details_renders_code() -> None:
render the CODE, not `undefined` and not an inert `CB0` sentinel.""" render the CODE, not `undefined` and not an inert `CB0` sentinel."""
out = _render("<details>\n<summary>x</summary>\n\n```py\nsecret_code()\n```\n\n</details>") out = _render("<details>\n<summary>x</summary>\n\n```py\nsecret_code()\n```\n\n</details>")
assert "secret_code()" in out, "code inside <details> was lost:\n" + out assert "secret_code()" in out, "code inside <details> was lost:\n" + out
assert "<pre><code" in out and 'class="language-py"' in out assert '<pre tabindex="0"><code' in out and 'class="language-py"' in out
assert "undefined" not in out assert "undefined" not in out
assert _NUL not in out, "a raw sentinel leaked (recursion did not see raw markdown):\n" + out assert _NUL not in out, "a raw sentinel leaked (recursion did not see raw markdown):\n" + out
@@ -1841,7 +1826,9 @@ def test_details_close_tag_shown_in_fenced_example_does_not_close_block() -> Non
tag; the fenced example renders as literal code inside the block.""" tag; the fenced example renders as literal code inside the block."""
md = "<details>\n<summary>s</summary>\n\n```html\n</details>\n```\n\n</details>" md = "<details>\n<summary>s</summary>\n\n```html\n</details>\n```\n\n</details>"
out = _render(md) out = _render(md)
assert '<pre><code class="language-html">' in out, "fenced example was swallowed:\n" + out assert '<pre tabindex="0"><code class="language-html">' in out, (
"fenced example was swallowed:\n" + out
)
assert "&lt;/details&gt;" in out, "example </details> should be literal code:\n" + out assert "&lt;/details&gt;" in out, "example </details> should be literal code:\n" + out
assert out.strip().startswith("<details><summary>s</summary>"), out assert out.strip().startswith("<details><summary>s</summary>"), out
assert out.rstrip().endswith("</details>"), "real block closed early / stray text:\n" + out assert out.rstrip().endswith("</details>"), "real block closed early / stray text:\n" + out
@@ -1853,7 +1840,7 @@ def test_deeply_indented_fence_renders_as_code() -> None:
tokenises as a code block — the open anchor allows arbitrary indent, so we tokenises as a code block — the open anchor allows arbitrary indent, so we
don't regress deeply-nested code samples to literal backticks.""" don't regress deeply-nested code samples to literal backticks."""
out = _render(" ```py\n x = 1\n ```") out = _render(" ```py\n x = 1\n ```")
assert "<pre><code" in out, "deeply-indented fence dropped:\n" + out assert '<pre tabindex="0"><code' in out, "deeply-indented fence dropped:\n" + out
assert "x = 1" in out assert "x = 1" in out
@@ -1866,7 +1853,9 @@ def test_fence_on_list_marker_line_renders_as_code() -> None:
backticks + language tag as text.""" backticks + language tag as text."""
for src in ["- ```py\n print(1)\n ```", "1. ```py\n print(1)\n ```"]: for src in ["- ```py\n print(1)\n ```", "1. ```py\n print(1)\n ```"]:
out = _render(src) out = _render(src)
assert "<pre><code" in out, "list-marker-line fence dropped:\n" + repr(src) + "\n" + out assert '<pre tabindex="0"><code' in out, (
"list-marker-line fence dropped:\n" + repr(src) + "\n" + out
)
assert "print(1)" in out assert "print(1)" in out
assert "```py" not in out, "raw fence backticks leaked as text:\n" + out assert "```py" not in out, "raw fence backticks leaked as text:\n" + out
assert "<li>" in out, "list structure lost:\n" + out assert "<li>" in out, "list structure lost:\n" + out
@@ -1879,7 +1868,7 @@ def test_nested_list_fence_stays_nested() -> None:
the indent flattened the code block to a top-level sibling of the parent.""" the indent flattened the code block to a top-level sibling of the parent."""
out = _render("- parent\n - ```py\n code\n ```") out = _render("- parent\n - ```py\n code\n ```")
assert "parent" in out assert "parent" in out
assert "<pre><code" in out and "```py" not in out assert '<pre tabindex="0"><code' in out and "```py" not in out
assert out.count("<ul>") == 2, "nested list fence flattened to a sibling:\n" + out assert out.count("<ul>") == 2, "nested list fence flattened to a sibling:\n" + out
@@ -1888,7 +1877,7 @@ def test_big_ordered_marker_fence_is_protected() -> None:
protected — the marker alternation uses ``\d+``, matching the list pass, protected — the marker alternation uses ``\d+``, matching the list pass,
not a capped ``\d{1,9}`` that would leave the fence unprotected.""" not a capped ``\d{1,9}`` that would leave the fence unprotected."""
out = _render("1234567890. ```py\ncode\n```") out = _render("1234567890. ```py\ncode\n```")
assert "<pre><code" in out, "big ordered-marker fence leaked as text:\n" + out assert '<pre tabindex="0"><code' in out, "big ordered-marker fence leaked as text:\n" + out
assert "```py" not in out assert "```py" not in out
@@ -1911,3 +1900,131 @@ def test_indented_details_is_extracted() -> None:
out = _render(" <details><summary>x</summary>y</details>") out = _render(" <details><summary>x</summary>y</details>")
assert "<details><summary>x</summary>" in out, "indented <details> not extracted:\n" + out assert "<details><summary>x</summary>" in out, "indented <details> not extracted:\n" + out
assert "y" in out assert "y" in out
# ---------------------------------------------------------------------------
# Table source stash — data-md-source for the copy affordance
# ---------------------------------------------------------------------------
def test_table_wrap_carries_exact_md_source() -> None:
"""The table pass trims cells and inline-renders them, so the original
pipe / alignment lines are unrecoverable from the DOM. The copy
affordance (copy_actions.js blockCopySource) reads the exact source
region from ``data-md-source``, stashed on ``.table-wrap`` at render
time — this pins both the attribute's presence and its exactness
(all rows, alignment line included, no surrounding prose)."""
table = "| a | b |\n|---|:--:|\n| 1 | 2 |\n| 3 | 4 |"
out = _render("Intro line\n\n" + table + "\n\nOutro line")
assert 'data-md-source="' + table + '"' in out, (
"table-wrap missing or carrying an inexact data-md-source:\n" + out
)
def test_table_md_source_restores_inline_spans_to_raw_source() -> None:
"""Table cells holding inline code / math are NUL-sentinel-masked by the
time the table pass slices its source region, and the global restore
passes run AFTER the table restore — i.e. INSIDE the emitted attribute.
Unfixed, a backtick cell put ``<code>`` markup on the clipboard and a
math cell spliced KaTeX HTML whose quotes TERMINATED the attribute
(spurious ``katex"`` attribute + source text leaking as page content).
The stash must round-trip the RAW author text for all three span kinds,
and no sentinel may survive anywhere in the output."""
for src in (
"| `x=1` | b |\n|---|---|\n| 1 | 2 |",
"| \\(x^2\\) | square |\n|---|---|\n| 1 | 2 |",
"| $$x$$ | b |\n|---|---|\n| 1 | 2 |",
):
out = _render(src)
m = re.search(r'data-md-source="([^"]*)"', out)
assert m is not None, "no intact data-md-source attribute:\n" + out
# Equality (not substring): the value is the exact raw source — no
# rendered substitutes, no stranded sentinels, and by construction
# no premature quote terminating the attribute early.
assert m.group(1) == src, (
"data-md-source did not round-trip raw for " + repr(src) + ":\n" + out
)
assert "<" not in m.group(1), "markup inside the source stash:\n" + out
assert "\x00" not in out, "a masking sentinel survived into the output"
# The rendered CELLS resolve too — tables restore before the inline-span
# passes, so display math in a cell renders as KaTeX instead of literal
# sentinel garbage (a pre-existing ordering bug this stash work exposed).
out = _render("| $$x$$ | b |\n|---|---|\n| 1 | 2 |")
assert '<span class="katex">' in out, "display math in a table cell did not render:\n" + out
def test_table_md_source_restores_nested_masks() -> None:
"""A math span in a cell can SWALLOW an inline-code sentinel: code is
masked FIRST, so the math raw twin carries the code's sentinel rather
than its text. The attribute restore must run in reverse mask order
(math before inline code) — restored in mask order, the swallowed
sentinel surfaces after its own pass already ran, falls to the
foreign-residue strip, and the copied source silently loses the code
span under a success flash."""
md = "| $$x `y` z$$ | \\(p `q` r\\) |\n|---|---|\n| 1 | 2 |"
out = _render(md)
m = re.search(r'data-md-source="([^"]*)"', out)
assert m is not None, "no intact data-md-source attribute:\n" + out
assert m.group(1) == md, "nested code-in-math did not round-trip raw:\n" + out
def test_table_md_source_escapes_attribute_breakout_chars() -> None:
"""A cell containing quotes or angle brackets must stay entity-escaped
inside the attribute value — a raw ``"`` would end the attribute and
let cell text inject markup."""
out = _render('| say "hi" & <b> | b |\n|---|---|\n| 1 | 2 |')
assert 'data-md-source="| say &quot;hi&quot; &amp; &lt;b&gt; | b |' in out, (
"quote / angle-bracket escaping missing in data-md-source:\n" + out
)
assert 'data-md-source="| say "hi"' not in out
def test_table_md_source_aligns_raw_twins_at_nonzero_index() -> None:
"""The raw-twin restore is index-aligned with the mask arrays; spans in
PROSE before the table push the table's own spans to non-zero raw
indices, so a push-to-one-array-only regression would splice a
DIFFERENT span's source into the stash. Index 0 alone cannot catch
that."""
table = "| `x=1` | \\(y^2\\) |\n|---|---|\n| 1 | 2 |"
out = _render("Use `a` and \\(z\\) first.\n\n" + table)
m = re.search(r'data-md-source="([^"]*)"', out)
assert m is not None, "no intact data-md-source attribute:\n" + out
assert m.group(1) == table, (
"non-zero-index raw twins did not round-trip the table's own spans:\n" + out
)
def test_footnote_frame_table_stash_round_trips_spans() -> None:
"""A table inside a FOOTNOTE DEFINITION renders in a recursive frame.
The body is collected AFTER the outer inline-span masks, so its cells
carry OUTER-frame sentinels — restored to raw source before the
recursive render (restoreRawSpans), which re-masks them itself so its
own stash resolves them against its own raw twins. Left unrestored,
the stash's residue strip silently DELETED the cell content from the
copied source under a success flash (and without the strip, the
outer restores would splice rendered HTML — attribute-terminating
quotes included — into the attribute after the fact)."""
out = _render("Ref[^a].\n\n[^a]: note\n | h | v |\n |---|---|\n | $$y$$ | `c` |")
m = re.search(r'data-md-source="([^"]*)"', out)
assert m is not None, "no intact data-md-source attribute:\n" + out
assert m.group(1) == "| h | v |\n|---|---|\n| $$y$$ | `c` |", (
"footnote-frame table stash did not round-trip its cells' raw source:\n" + out
)
assert "\x00" not in out, "a masking sentinel survived into the output"
assert '<span class="katex">' in out, (
"the footnote table's math cell should render in the recursive frame:\n" + out
)
def test_table_md_source_carries_ragged_rows_whole() -> None:
"""Copy is SOURCE copy, uniformly: a row wider than the header renders
truncated (the loop emits hdrCells.length cells) but the stash carries
the block's whole raw source — the same contract as message copy.
What is shown is the render's decision; what is copied is what was
written."""
src = "| a | b |\n|---|---|\n| 1 | 2 | extra |"
out = _render(src)
m = re.search(r'data-md-source="([^"]*)"', out)
assert m is not None, "no intact data-md-source attribute:\n" + out
assert m.group(1) == src, "ragged table did not round-trip its whole source:\n" + out
assert ">extra<" not in out, "overflow cell leaked into the RENDERED table"
+2
View File
@@ -52,6 +52,7 @@ _ESM_BUNDLES = [
_SHARED / "preview.js", _SHARED / "preview.js",
_SHARED / "redact_credentials.js", _SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js", _SHARED / "mcp_error.js",
_SHARED / "copy_actions.js",
] ]
# Sink scan: everything except renderer.js — the one sanctioned HTML-string # Sink scan: everything except renderer.js — the one sanctioned HTML-string
@@ -74,6 +75,7 @@ _ESM_NO_VAR_BUNDLES = [
_SHARED / "preview.js", _SHARED / "preview.js",
_SHARED / "redact_credentials.js", _SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js", _SHARED / "mcp_error.js",
_SHARED / "copy_actions.js",
] ]
# The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py`` # The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py``
+30 -6
View File
@@ -3670,12 +3670,9 @@ function hideTokenCreatedModal() {
function copyCreatedToken() { function copyCreatedToken() {
if (!_lastCreatedToken) return; if (!_lastCreatedToken) return;
if (navigator.clipboard) { // Select the token text so a manual copy is one keystroke away — the
navigator.clipboard.writeText(_lastCreatedToken).then(function () { // landing spot whenever no automatic path succeeded.
showToast("Token copied to clipboard"); function selectTokenFallback() {
});
} else {
// Fallback: select the text
const el = document.getElementById("token-created-value"); const el = document.getElementById("token-created-value");
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
@@ -3684,6 +3681,33 @@ function copyCreatedToken() {
sel.addRange(range); sel.addRange(range);
showToast("Select and copy the token"); showToast("Select and copy the token");
} }
// copyTextToClipboard (utils.js) covers the plain-HTTP LAN case via its
// legacy execCommand path. The one-time token dialog must keep working
// with ZERO module dependencies (this is a classic script; the bridge
// is absent whenever the module lane failed), so with no bridge the
// native async API is still attempted directly — most degraded pages
// are HTTPS consoles where it works — before degrading to the manual
// selection, never to a throw.
if (typeof window.copyTextToClipboard !== "function") {
if (navigator.clipboard) {
navigator.clipboard
.writeText(_lastCreatedToken)
.then(function () {
showToast("Token copied to clipboard");
})
.catch(selectTokenFallback);
} else {
selectTokenFallback();
}
return;
}
window.copyTextToClipboard(_lastCreatedToken).then(function (ok) {
if (ok) {
showToast("Token copied to clipboard");
return;
}
selectTokenFallback();
});
} }
// Escape closes any open settings help popover (the settings panels and the // Escape closes any open settings help popover (the settings panels and the
@@ -881,6 +881,12 @@ function createCoordinatorPane(root, wsId, opts) {
return UNKNOWN_AUTO_APPROVE_REASON; return UNKNOWN_AUTO_APPROVE_REASON;
} }
// The <pre> exits carry tabindex=0: any pre inside a .msg-body hosts
// the pointer copy affordance (copy_actions.js), and the keyboard copy
// path acts on the FOCUSED block — an unfocusable pre would be
// copyable in one click by pointer and unreachable by keyboard. They
// are horizontal scroll regions too, which keyboard users must be
// able to reach regardless.
function renderToolOutput(rawText) { function renderToolOutput(rawText) {
// Try parse JSON first — coordinator tool output is JSON-shaped. // Try parse JSON first — coordinator tool output is JSON-shaped.
let parsed = null; let parsed = null;
@@ -901,7 +907,7 @@ function createCoordinatorPane(root, wsId, opts) {
} }
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
"<pre>" + '<pre tabindex="0">' +
esc(redactCredentials(JSON.stringify(parsed, null, 2))) + esc(redactCredentials(JSON.stringify(parsed, null, 2))) +
"</pre>" "</pre>"
); );
@@ -934,7 +940,7 @@ function createCoordinatorPane(root, wsId, opts) {
" " + (link || esc("?")) + (meta.length ? " " + meta.join(" ") : "") " " + (link || esc("?")) + (meta.length ? " " + meta.join(" ") : "")
); );
}); });
return "<pre>" + lines.join("\n") + "</pre>"; return '<pre tabindex="0">' + lines.join("\n") + "</pre>";
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -1001,6 +1007,14 @@ function createCoordinatorPane(root, wsId, opts) {
body.className = "msg-body"; body.className = "msg-body";
setSafeHtml(body, html); setSafeHtml(body, html);
el.appendChild(body); el.appendChild(body);
// Every assistant bubble gets a persistent copy button at creation
// (O(1) per message — appendMsg is the single chokepoint for assistant
// bubbles). Keyed on the literal role, not the variant fallback, so
// unknown roles don't grow one. The transient retry button joins this
// bar on the last bubble via _addRetryAction / _refreshRetryButton.
if (role === "assistant" && typeof buildMsgCopyButton === "function") {
ensureMsgActionsBar(el).appendChild(buildMsgCopyButton(el));
}
messagesEl.appendChild(el); messagesEl.appendChild(el);
_scheduleScroll(); _scheduleScroll();
return el; return el;
@@ -5947,32 +5961,63 @@ function createCoordinatorPane(root, wsId, opts) {
} }
function _addRetryAction(el) { function _addRetryAction(el) {
let bar = el.querySelector(".msg-actions"); // Retry must render with ZERO module dependencies — it predates the
if (!bar) { // shared module lane and an operator on a degraded page (module fetch
bar = document.createElement("div"); // failure) still needs regenerate. The shared builders are preferred
bar.className = "msg-actions"; // when bridged; the fallback twins ensureMsgActionsBar /
bar.setAttribute("role", "toolbar"); // buildMsgRetryButton exactly (same classes — the .msg-retry-btn
bar.setAttribute("aria-label", "Message actions"); // selector is load-bearing for the teardown sweep — same
el.appendChild(bar); // role/labels), so the two paths render identically.
let bar = null;
let btn = null;
if (
typeof ensureMsgActionsBar === "function" &&
typeof buildMsgRetryButton === "function"
) {
bar = ensureMsgActionsBar(el);
btn = buildMsgRetryButton(_retryLast);
} else {
for (let i = 0; i < el.children.length; i++) {
if (el.children[i].classList.contains("msg-actions")) {
bar = el.children[i];
break;
}
}
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
btn = document.createElement("button");
btn.className = "msg-action-btn msg-retry-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
const icon = document.createElement("span");
icon.className = "icon-retry";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", (e) => {
e.stopPropagation();
_retryLast();
});
} }
const btn = document.createElement("button");
btn.className = "msg-action-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
const icon = document.createElement("span");
icon.className = "icon-retry";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", (e) => {
e.stopPropagation();
_retryLast();
});
bar.insertBefore(btn, bar.firstChild); bar.insertBefore(btn, bar.firstChild);
} }
function _refreshRetryButton() { function _refreshRetryButton() {
const old = messagesEl.querySelectorAll(".msg.assistant .msg-actions"); // Remove only the transient retry buttons — a bar that still holds
for (let i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]); // other controls (the copy button appendMsg attaches) persists. On
// the degraded no-bridge path the fallback bar held ONLY retry, and
// an empty toolbar is a screen-reader landmark with zero controls,
// so an emptied bar goes with its last button.
const old = messagesEl.querySelectorAll(".msg.assistant .msg-retry-btn");
for (let i = 0; i < old.length; i++) {
const bar = old[i].parentNode;
bar.removeChild(old[i]);
if (!bar.children.length) bar.remove();
}
// Skip retry when the most recent semantic turn is tool-only (last DOM // Skip retry when the most recent semantic turn is tool-only (last DOM
// child is a .conv-batch construct); walk back past operator-context // child is a .conv-batch construct); walk back past operator-context
// rows first — the plain system bubble AND the structured watch-result / // rows first — the plain system bubble AND the structured watch-result /
+169 -15
View File
@@ -857,18 +857,9 @@
} }
} }
/* ========================================================================== /* Reduced-motion overrides live in ONE block at the end of this file a
Reduced motion disable chat.css transitions. transition: none here would lose to the equal-specificity component rules
========================================================================== */ declared below it. */
@media (prefers-reduced-motion: reduce) {
.msg-actions,
.msg-action-btn,
.composer-attach,
.composer-input,
.composer-send {
transition: none;
}
}
/* ========================================================================== /* ==========================================================================
Message primitive chat-style single-column message block. Semantic Message primitive chat-style single-column message block. Semantic
@@ -1622,6 +1613,11 @@
outline-offset: 1px; outline-offset: 1px;
} }
/* Slot order is DOM order retry prepends, TTS inserts before the
persistent copy button, copy anchors the corner on every bubble so
visual order and tab order always agree (no flex `order`, which would
split the two). */
/* Touch-only: keep actions visible; hover-reveal isn't reachable. /* Touch-only: keep actions visible; hover-reveal isn't reachable.
`(pointer: coarse)` narrows the match to true touch devices `(pointer: coarse)` narrows the match to true touch devices
`(hover: none)` alone fires on stylus and some laptops too. Matches `(hover: none)` alone fires on stylus and some laptops too. Matches
@@ -1644,7 +1640,7 @@
} }
/* ========================================================================== /* ==========================================================================
Per-message rewind / edit / retry affordance (#549) Per-message rewind / edit / retry (#549) + copy affordances
Icon glyphs rendered inside the .msg-action-btn toolbar above, plus the Icon glyphs rendered inside the .msg-action-btn toolbar above, plus the
inline edit-in-place form. Shared by the interactive pane (ui/static) and inline edit-in-place form. Shared by the interactive pane (ui/static) and
the coordinator dashboard (console/static/coordinator); both load this file the coordinator dashboard (console/static/coordinator); both load this file
@@ -1728,6 +1724,148 @@
left: 6px; left: 6px;
} }
/* Icon: copy (two offset sheets). The back sheet draws only its top and
right edges so the front sheet needs no occluding background the
button renders over arbitrary surfaces (bubble, code, floating). */
.icon-copy {
width: 12px;
height: 12px;
position: relative;
}
.icon-copy::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
border-top: 1.5px solid currentColor;
border-right: 1.5px solid currentColor;
border-radius: 0 2px 0 0;
box-sizing: border-box;
}
.icon-copy::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 9px;
height: 9px;
border: 1.5px solid currentColor;
border-radius: 2px;
box-sizing: border-box;
}
/* Copy outcome flash the icon swaps to a check / cross alongside the
title text set by copy_actions.js (shape + prose, never colour alone).
The outcome classes are written only onto the two copy buttons, and the
.icon-copy descendant scopes the rules one selector serves both. */
.is-copied .icon-copy::before {
display: none;
}
.is-copied .icon-copy::after {
border: none;
border-left: 2px solid var(--ok);
border-bottom: 2px solid var(--ok);
border-radius: 0;
width: 9px;
height: 5px;
left: 1px;
bottom: 3px;
transform: rotate(-45deg);
}
.is-copy-failed .icon-copy::before,
.is-copy-failed .icon-copy::after {
display: block;
border: none;
border-top: 2px solid var(--err);
border-radius: 1px;
width: 11px;
height: 0;
top: 5px;
left: 0;
right: auto;
bottom: auto;
}
.is-copy-failed .icon-copy::before {
transform: rotate(45deg);
}
.is-copy-failed .icon-copy::after {
transform: rotate(-45deg);
}
/* Floating block-copy button positioned (fixed) by copy_actions.js over
the hovered code fence / mermaid diagram / table. Deliberately never
inside rendered block DOM: streaming innerHTML replacement and the
source-keyed hljs cache would eat an embedded button. Sits above pane
chrome (z 10) and below overlays (z 999). */
.block-copy-btn {
/* Hidden via visibility (not display): the button stays out of the tab
order and hit-testing while hidden, and the show toggle repaints
reliably in headless captures (display:none -> flex on a late-inserted
fixed element skipped the compositor frame in the livepass shots). */
visibility: hidden;
opacity: 0;
display: inline-flex;
position: fixed;
z-index: 100;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
padding: 0;
color: var(--ink-3);
/* The button floats over arbitrary content whose backgrounds sit within
a token step of every elevated fill (in light theme --bg-elevated IS
the bubble colour), so the BORDER is the component boundary: mixed
from --ink-3 it holds ~3:1 against the panel tones in both themes
(WCAG 1.4.11), with the shadow as depth, not as the only separator. */
background: var(--bg-elevated);
border: 1px solid color-mix(in srgb, var(--ink-3) 45%, transparent);
border-radius: var(--r-sm);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
transition:
color 120ms ease,
border-color 120ms ease;
}
.block-copy-btn.is-visible {
visibility: visible;
opacity: 1;
}
.block-copy-btn:hover {
color: var(--ink);
border-color: var(--accent);
}
/* The focusable blocks (fences and diagram containers carry tabindex=0
for scrollable-region access and the Enter-to-copy keyboard path;
tables already did) get the house focus ring instead of the UA
default. */
.msg-body pre:focus-visible,
.table-wrap:focus-visible,
.mermaid-container:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Keyboard block copy (Enter on the focused block) flashes its outcome
on the block itself: solid ok ring for copied, dashed err ring for
failed shape + colour, with the prose carried by the live-region
announcement. The classes are transient (copy_actions.js reverts
them after the flash). */
.msg-body pre.is-copied,
.table-wrap.is-copied,
.mermaid-container.is-copied {
outline: 2px solid var(--ok);
outline-offset: 1px;
}
.msg-body pre.is-copy-failed,
.table-wrap.is-copy-failed,
.mermaid-container.is-copy-failed {
outline: 2px dashed var(--err);
outline-offset: 1px;
}
/* Edit-in-place form */ /* Edit-in-place form */
.msg-edit-form { .msg-edit-form {
display: flex; display: flex;
@@ -1797,7 +1935,11 @@
display: none; display: none;
} }
/* Busy-state disables action buttons */ /* Busy-state disables action buttons copy included: every copy
affordance is idle-only (the pane-level data-busy stamp is the one
gate). Visual + pointer here; the click paths in copy_actions.js
refuse independently, since pointer-events cannot stop a keyboard
activation. */
[data-busy="true"] .msg-action-btn { [data-busy="true"] .msg-action-btn {
opacity: 0.3; opacity: 0.3;
pointer-events: none; pointer-events: none;
@@ -1874,8 +2016,20 @@
.ws-status-bar.ws-sb-disconnected .ws-sb-turns { .ws-status-bar.ws-sb-disconnected .ws-sb-turns {
opacity: 0.4; opacity: 0.4;
} }
/* ==========================================================================
Reduced motion the ONE consolidated block, kept at the END of the file:
these are equal-specificity overrides, so any component rule declared
after them would silently win (that is exactly how the previous copy of
this block near the top of the file was dead on arrival).
========================================================================== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ws-status-bar { .ws-status-bar,
.msg-actions,
.msg-action-btn,
.block-copy-btn,
.composer-attach,
.composer-input,
.composer-send {
transition: none; transition: none;
} }
} }
+517
View File
@@ -0,0 +1,517 @@
/* copy_actions.js copy-to-clipboard affordances for rendered chat content.
Three affordances share this module, and every one is IDLE-ONLY: while
a turn is in flight (the chat clients stamp data-busy="true" on their
messages container) nothing here copies. A busy transcript is mutating
under the affordance streaming replaces bubble bodies per rAF tick,
and a mid-stream whole-message copy would land a silent prefix under a
success flash so the gate is one pane-level fact, enforced in JS at
every click/activation path (the chat.css grey-out alone would be
keyboard-bypassable) and reflected visually by chat.css.
* Bubble copy: buildMsgCopyButton() builds the per-message copy button
the chat clients mount in their ``.msg-actions`` bars.
* Block copy, pointer: one floating button (``.block-copy-btn``) that
appears over the hovered markdown block code fence, mermaid
diagram, or table and copies that block's SOURCE. Pointer-only:
pointer entering the block reveals it; pointer leaving it, or any
scroll in its context, hides it, unconditionally. It sits out of
the tab order (tabindex -1) and never carries keyboard state.
Nothing is ever injected into rendered block DOM: streamingRender
replaces bubble innerHTML wholesale on every rAF tick, and
postRenderHljs caches code innerHTML by source, so an embedded
button would be wiped per tick or captured into the cache. Touch
has no hover: block copy is desktop-first, and touch keeps the
always-visible bubble copy.
* Block copy, keyboard: Enter on a FOCUSED block (pre, .table-wrap and
.mermaid-container all carry tabindex=0) copies that block directly
the floating button is never involved. The outcome flashes on the
block itself (is-copied / is-copy-failed, chat.css) and the live
region announces it.
Copy resolves to SOURCE, not rendered text pasting a lifted snippet
into an editor or another chat must keep the fences and pipes:
* whole message the raw markdown the streaming pipeline stashes on
``.msg-body`` (``_copySource``, renderer.js _streamingRenderApply);
* code fence ``code.textContent`` (escapeHtml round-trips, and hljs
wraps tokens in spans without changing text content);
* mermaid the container's ``data-mermaid-source`` (the autoquoted
source the diagram was actually rendered from);
* table the ``data-md-source`` the table pass stashes at render time
(pipes and alignment markers are unrecoverable from rendered cells).
ES module; renderer.js imports it for side effects so the affordance
lands on every surface that renders markdown. Window bridge at the
bottom for the still-classic consumers (coordinator.js). */
import { copyTextToClipboard, makeAnnouncer } from "./utils.js";
const FLASH_MS = 1400;
const FAIL_TEXT = "Copy failed — select the text and copy manually";
const BUSY_TEXT = "Copy is available when the reply finishes";
// Shared polite live region announcing copy outcomes to screen readers —
// the visual outcome flash is not announced on its own. (Eager region
// creation and the clear-then-set idiom live in makeAnnouncer.)
const _announce = makeAnnouncer();
// The pane-level busy gate. Both chat clients maintain data-busy on
// their messages container for the whole turn (interactive Pane.setBusy,
// coordinator setBusy), so any node inside a busy transcript resolves the
// same one fact.
function _isBusy(el) {
return !!el.closest('[data-busy="true"]');
}
// Return a flashed element — a copy button, or the block a keyboard copy
// targeted — to its idle presentation: outcome classes off, idle title
// back, any pending revert timer cancelled, and any in-flight copy
// orphaned (its settle must not paint an outcome the element no longer
// owns). Shared by the flash revert and the floating button's re-target
// reset so the two cannot drift.
function _clearFlash(el) {
el._copyGen = (el._copyGen || 0) + 1;
if (el._copyFlashTimer) clearTimeout(el._copyFlashTimer);
el._copyFlashTimer = 0;
el.classList.remove("is-copied", "is-copy-failed");
// Buttons restore their idle tooltip; flashed BLOCKS (the keyboard
// copy path) have none — restore to empty rather than stamping a
// literal "undefined".
el.title = el._copyIdleTitle || "";
}
// Flash an outcome: the state class swaps a button's icon to a check /
// cross (or tints a keyboard-copied block), the title carries the
// plain-language explanation, the live region announces the same
// string, and everything reverts after FLASH_MS. ✓/✗ + prose only — no
// partial states, and deliberately no toast or other notification
// chrome: the outcome surfaces identically on every page, at the
// element the user acted on.
function _flashOutcome(el, ok, text) {
// Every flash is a NEW outcome: bump the generation so any still-in-
// flight copy on this element is orphaned — a slow success settling
// after a later failure must not flip the ✗ (and its recovery title)
// back to a ✓ the second interaction never earned.
el._copyGen = (el._copyGen || 0) + 1;
el.classList.remove("is-copied", "is-copy-failed");
el.classList.add(ok ? "is-copied" : "is-copy-failed");
el.title = text;
_announce(text);
if (el._copyFlashTimer) clearTimeout(el._copyFlashTimer);
el._copyFlashTimer = setTimeout(function () {
_clearFlash(el);
}, FLASH_MS);
}
function _flashCopyResult(el, ok) {
_flashOutcome(el, ok, ok ? "Copied" : FAIL_TEXT);
}
// A busy refusal is an OUTCOME, not a silent no-op: every activation
// path answers, or a keyboard user cannot distinguish "refused because
// the reply is streaming" from "keystroke lost". FAIL_TEXT's manual-
// copy recovery would be misleading here — the content is still being
// written — so the refusal carries its own explanation.
function _flashBusyRefusal(el) {
_flashOutcome(el, false, BUSY_TEXT);
}
function _copyAndFlash(el, text) {
// The write is attempted for EVERY resolved source, empty included: a
// legitimately empty block copies the empty string, and the flash
// reports the transport's verdict — a manufactured failure (with its
// "select the text" recovery hint) on a block that has nothing to
// select would be a false error.
//
// A new copy claims the element's outcome slot outright: _clearFlash
// cancels a still-armed revert timer from the PREVIOUS flash — left
// running, it would fire mid-write, bump the generation, and orphan
// this copy (clipboard written, zero feedback) — and returns the
// element to idle while the write is in flight.
_clearFlash(el);
// Generation-stamped: the clipboard write settles asynchronously, and a
// re-target in that gap must orphan this copy's outcome — otherwise the
// ✓ (and its announcement) lands on a block the user never copied.
const gen = (el._copyGen = (el._copyGen || 0) + 1);
copyTextToClipboard(text).then(function (ok) {
if (el._copyGen === gen) _flashCopyResult(el, ok);
});
}
// The ASSISTANT-bubble action-bar adders (copy/retry/TTS in both chat
// clients) share this pair so their ARIA contract cannot drift between
// bubbles. (The user-bubble adders predate it and still build their bars
// in place.) The bar is always a DIRECT child of .msg, so the finder
// scans children instead of the whole rendered subtree (a bubble holding
// a large table is thousands of nodes).
export function findMsgActionsBar(el) {
for (const c of el.children) {
if (c.classList.contains("msg-actions")) return c;
}
return null;
}
export function ensureMsgActionsBar(el) {
let bar = findMsgActionsBar(el);
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
return bar;
}
// Resolve the copyable source for a rendered markdown block.
export function blockCopySource(el) {
if (!el) return "";
if (el.classList.contains("mermaid-container")) {
return el.getAttribute("data-mermaid-source") || "";
}
if (el.classList.contains("table-wrap")) {
return el.getAttribute("data-md-source") || "";
}
const code = el.querySelector("code");
return (code || el).textContent || "";
}
// Resolve the copyable source for a whole message bubble. _copySource is
// the render pipeline's unconditional stash — set for every applied frame,
// including ones whose markdown render threw and painted as plain text.
// The contract is WHOLE-SOURCE: the clipboard carries exactly what the
// model wrote, including syntax the renderer does not display (comment
// constructs, over-wide table cells, reference definitions) — what is
// SHOWN is the render's decision, what is COPIED is the source. Falls
// back to the rendered text only for a body that never went through the
// render pipeline at all: the visible text, an honest degrade.
export function msgCopySource(msgEl) {
const body = msgEl && msgEl.querySelector(".msg-body");
if (!body) return "";
return body._copySource != null ? body._copySource : body.textContent || "";
}
// Glyph span for an action button. aria-hidden: the button's accessible
// name lives in its aria-label — the icon is decoration.
function _icon(cls) {
const icon = document.createElement("span");
icon.className = cls;
icon.setAttribute("aria-hidden", "true");
return icon;
}
// Build the transient retry button both chat clients prepend to the last
// assistant bubble's bar. Lives here so the button's chrome — the
// load-bearing ``msg-retry-btn`` class (both teardown sweeps select on
// it), title and accessible name — cannot drift between clients.
export function buildMsgRetryButton(onRetry) {
const btn = document.createElement("button");
btn.className = "msg-action-btn msg-retry-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
btn.appendChild(_icon("icon-retry"));
btn.addEventListener("click", function (e) {
e.stopPropagation();
onRetry();
});
return btn;
}
// Build the per-message copy button for a ``.msg-actions`` bar. The
// source resolves at CLICK time, so a button attached when the bubble is
// created copies the final streamed content, not a creation-time snapshot.
export function buildMsgCopyButton(msgEl) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "msg-action-btn msg-copy-btn";
btn.title = "Copy message";
btn._copyIdleTitle = "Copy message";
btn.setAttribute("aria-label", "Copy message");
btn.appendChild(_icon("icon-copy"));
btn.addEventListener("click", function (e) {
e.stopPropagation();
// The busy refusal runs HERE, not just in chat.css: pointer-events
// cannot stop Enter on a button that already holds focus — and it
// answers (✗ + announce) rather than silently dropping a keystroke
// on a button assistive tech presents as enabled.
if (_isBusy(msgEl)) {
_flashBusyRefusal(btn);
return;
}
_copyAndFlash(btn, msgCopySource(msgEl));
});
return btn;
}
// ---------------------------------------------------------------------------
// Block copy — pointer path (floating button) + keyboard path (Enter)
// ---------------------------------------------------------------------------
const BLOCK_SELECTOR = "pre, .mermaid-container, .table-wrap";
// The button's box for the placement clamps below. The CSS is the source
// (chat.css .block-copy-btn width/height); these are its pinned mirror —
// a test asserts the two stay equal, so a CSS resize cannot silently
// desync the clamps. FAB_GAP is placement-only and has no CSS twin.
const FAB_W = 28;
const FAB_H = 24;
const FAB_GAP = 4;
let _fab = null;
// The ONE module-level DOM ref. Everything else placement needs — the
// owning bubble, its scroller, the action bar — is derived from the
// target at use time, so hide has exactly one ref to drop and a wiped
// transcript leaves nothing else to strand.
let _fabTarget = null;
// A block nested inside another block is a rendering detail, not a copy
// target: the mermaid error state draws its message + source as a <pre>
// INSIDE the .mermaid-container, and copying that pre would lift the
// error prose instead of the diagram source.
function _isNestedBlock(el) {
return !!(el.parentElement && el.parentElement.closest(BLOCK_SELECTOR));
}
// Outermost copyable block for an event target: a hit on a nested block
// lifts to its containing block, so the pointer and keyboard paths agree
// on one copy target per rendered block.
function _liftToOuterBlock(el) {
let block = el;
while (block && _isNestedBlock(block)) {
block = block.parentElement.closest(BLOCK_SELECTOR);
}
return block;
}
function _ensureFab() {
if (_fab) return _fab;
_fab = document.createElement("button");
_fab.type = "button";
_fab.className = "block-copy-btn";
_fab.title = "Copy block";
_fab._copyIdleTitle = "Copy block";
_fab.setAttribute("aria-label", "Copy block");
// Pointer-only: out of the tab order entirely. Keyboard users copy
// with Enter on the focused block (the delegated keydown below), so a
// tab stop here would only add a hover-dependent phantom to the page's
// focus order.
_fab.setAttribute("tabindex", "-1");
_fab.appendChild(_icon("icon-copy"));
_fab.addEventListener("click", function (e) {
e.stopPropagation();
// Reveal is idle-gated, so the DOM under a visible button is stable;
// what remains is a transcript wipe under the pointer (disconnected
// target → the generic ✗) or a turn starting between reveal and
// click (busy → its own refusal message). Both answer — never a
// silent no-op on a button that looks live, and never a hide before
// the outcome lands.
const target = _fabTarget;
if (!target || !target.isConnected) {
_flashCopyResult(_fab, false);
return;
}
if (_isBusy(target)) {
_flashBusyRefusal(_fab);
return;
}
_copyAndFlash(_fab, blockCopySource(target));
});
document.body.appendChild(_fab);
return _fab;
}
// Hide is stateless and unconditional: the pointer path carries no focus
// or keyboard state to preserve, so hiding is the class off plus dropping
// the target ref — a module-level ref must not pin a wiped transcript's
// detached bubble. Runs for every dismissal (pointer exit, scroll,
// window leave) and for the placement aborts in _showFabFor, so a reveal
// aborted while the button was still hidden cannot strand a stale ref
// that would dead-end the show fast-path for that block.
function _hideFab() {
if (!_fab) return;
_fab.classList.remove("is-visible");
_fabTarget = null;
}
// Nearest scrolling ancestor — the band the button must stay inside so it
// hugs the visible part of a tall block instead of pinning to the viewport
// (where it detaches from its block and lands over pane chrome).
// Memoized per bubble (WeakMap, so a wiped bubble is still collectable):
// the walk reads computed styles per ancestor, and the hide/re-show churn
// of an ordinary prose↔block pointer sweep would otherwise re-pay it on
// every crossing.
const _scrollerMemo = new WeakMap();
function _scrollerOf(el) {
let sc = el.parentElement;
while (sc && sc !== document.body) {
const o = getComputedStyle(sc).overflowY;
if (o === "auto" || o === "scroll" || o === "overlay") return sc;
sc = sc.parentElement;
}
return null;
}
function _scrollerFor(host) {
let sc = _scrollerMemo.get(host);
if (sc === undefined) {
sc = _scrollerOf(host);
_scrollerMemo.set(host, sc);
}
return sc;
}
function _showFabFor(target) {
const fab = _ensureFab();
if (target !== _fabTarget) {
// A new target never inherits the previous one's outcome flash — a ✓
// carried across blocks would assert a copy the user never made there.
_clearFlash(fab);
_fabTarget = target;
}
const host = target.closest(".msg") || document.body;
const scroller = _scrollerFor(host);
const rect = target.getBoundingClientRect();
const band = scroller
? scroller.getBoundingClientRect()
: { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
// The button lives in the visible intersection of block and scroller; a
// sliver under button height means there is nothing sensible to anchor
// to, so hide instead of hovering over unrelated chrome.
const visTop = Math.max(rect.top, band.top);
const visBottom = Math.min(rect.bottom, band.bottom);
if (visBottom - visTop < FAB_H + 2 * FAB_GAP) {
_hideFab();
return;
}
let top = Math.min(visTop + FAB_GAP, visBottom - FAB_H - FAB_GAP);
let left = Math.max(rect.right - FAB_W - FAB_GAP, rect.left + FAB_GAP);
left = Math.min(
left,
window.innerWidth - FAB_W - 2 * FAB_GAP,
band.right - FAB_W - 2 * FAB_GAP,
);
left = Math.max(left, band.left + FAB_GAP);
// A block that opens the bubble puts this button on top of the bubble's
// own hover-revealed copy button (same glyph, different scope) — drop
// below the action bar when the two would collide. Looked up live (a
// few direct children), never cached: the retry/TTS holder grows the
// bar after the fact. When the band clamp leaves nowhere below the
// bar, hide instead: two stacked identical copy glyphs make a click
// that targets one silently hit the other.
const bar = host === document.body ? null : findMsgActionsBar(host);
if (bar) {
const br = bar.getBoundingClientRect();
if (
br.width > 0 &&
top < br.bottom + FAB_GAP &&
top + FAB_H > br.top &&
left < br.right + FAB_GAP &&
left + FAB_W > br.left
) {
top = Math.min(br.bottom + FAB_GAP, visBottom - FAB_H - FAB_GAP);
if (top < br.bottom + FAB_GAP && top + FAB_H > br.top) {
_hideFab();
return;
}
}
}
fab.style.top = top + "px";
fab.style.left = left + "px";
fab.classList.add("is-visible");
}
// Delegated hover: reveal over the hovered block, hide otherwise. Scoped
// to blocks inside rendered chat bodies (``.msg-body``) — other surfaces
// (composer previews, admin panes) keep their own affordances — and gated
// on the pane being idle: the button never REVEALS while a turn is in
// flight, and a pointer move over a busy transcript hides any reveal a
// just-started turn overtook.
document.addEventListener("mouseover", function (e) {
const t = e.target;
if (!(t instanceof Element)) return;
if (_fab && _fab.contains(t)) return; // hovering the button itself
// Fast path: the pointer moving WITHIN the targeted block is the
// dominant case (hljs wraps every token in a span, so a sweep across a
// fence is hundreds of events) — a containment check plus the busy
// gate, no selector walks. The busy check rides the fast path too: a
// turn starting under a shown button dismisses it on the NEXT pointer
// move, not only once the pointer leaves the block.
if (_fabTarget && _fabTarget.contains(t)) {
if (_isBusy(_fabTarget)) _hideFab();
return;
}
// The clipboard fallback's off-screen textarea (utils.js) must not
// count as "pointer left the block" — copying would dismiss its own
// button mid-copy. It is childless, so it is always the event target
// itself: a plain attribute check, no ancestor walk.
if (t.hasAttribute("data-clipboard-shim")) return;
const block = _liftToOuterBlock(t.closest(BLOCK_SELECTOR));
if (block && block.closest(".msg-body") && !_isBusy(block)) {
if (block !== _fabTarget) _showFabFor(block);
} else {
_hideFab();
}
});
// A scroll that MOVES the block out from under the fixed-position button
// — the document itself, or any scrollable ancestor holding the block —
// hides it rather than chasing (the pointer re-reveals in place). A
// scroll INSIDE the block (a fence's internal horizontal pan, a nested
// scrollable) pans content within it and keeps the button, and a
// scroller that does not contain the block (a sidebar) moves nothing the
// button is anchored to. Containment is the whole rule — no scroller
// identity to memoize or get wrong.
document.addEventListener(
"scroll",
function (e) {
if (!_fab || !_fab.classList.contains("is-visible")) return;
const movesBlock =
!_fabTarget ||
e.target === document ||
(e.target instanceof Element &&
e.target !== _fabTarget &&
e.target.contains(_fabTarget));
if (movesBlock) _hideFab();
},
true,
);
// Pointer leaving the window would otherwise strand the button painted
// over the page with nothing left to dismiss it.
document.addEventListener("mouseleave", function () {
_hideFab();
});
// Keyboard path: Enter on a focused block copies it directly — the
// floating button is never involved. Only the block ITSELF as the event
// target counts (a focusable descendant, e.g. a link in a table cell,
// keeps its own Enter semantics), a hit on a nested block lifts to its
// container, and the busy gate applies like every other copy path. The
// outcome flashes on the block (is-copied / is-copy-failed, chat.css)
// and the live region announces it.
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
// Chords belong to the browser / OS (Ctrl+Enter, Alt+Enter…) and key
// repeat is never a deliberate copy — a copy overwrites the user's
// clipboard, so only a plain, single Enter qualifies.
if (e.repeat || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest(BLOCK_SELECTOR) !== t) return;
const block = _liftToOuterBlock(t);
if (!block || !block.closest(".msg-body")) return;
if (_isBusy(block)) {
_flashBusyRefusal(block);
return;
}
_copyAndFlash(block, blockCopySource(block));
});
// --- Legacy window bridge ---------------------------------------------------
// coordinator.js (still classic) reaches these as globals at message-append
// time, well after this deferred module evaluated. Only its consumers are
// bridged; module code imports instead.
Object.assign(window, {
buildMsgCopyButton,
buildMsgRetryButton,
ensureMsgActionsBar,
});
+74 -81
View File
@@ -55,7 +55,13 @@ import {
} from "./composer_queue.js"; } from "./composer_queue.js";
import { StatusBar } from "./status_bar.js"; import { StatusBar } from "./status_bar.js";
import { streamingRender, streamingRenderFinalize } from "./renderer.js"; import { streamingRender, streamingRenderFinalize } from "./renderer.js";
import { setMarkdown, operatorSourceLabel } from "./utils.js"; import {
buildMsgCopyButton,
buildMsgRetryButton,
ensureMsgActionsBar,
findMsgActionsBar,
} from "./copy_actions.js";
import { makeAnnouncer, operatorSourceLabel } from "./utils.js";
import { import {
OVERFLOW_TRIP_COUNT, OVERFLOW_TRIP_COUNT,
OVERFLOW_TRIP_WINDOW_MS, OVERFLOW_TRIP_WINDOW_MS,
@@ -107,45 +113,22 @@ function getVoiceRoles(base) {
// Visually-hidden polite live region for voice status (recording / playback) // Visually-hidden polite live region for voice status (recording / playback)
// so screen-reader users perceive state changes otherwise conveyed only by // so screen-reader users perceive state changes otherwise conveyed only by
// color/icon. Errors go through showToast (already a live region). Single // color/icon. Errors go through showToast (already a live region).
// shared node; clear-then-set so repeated identical messages re-announce. const voiceAnnounce = makeAnnouncer();
let _voiceStatusEl = null;
function voiceAnnounce(msg) {
if (!_voiceStatusEl) {
_voiceStatusEl = document.createElement("div");
_voiceStatusEl.className = "sr-only";
_voiceStatusEl.setAttribute("role", "status");
_voiceStatusEl.setAttribute("aria-live", "polite");
document.body.appendChild(_voiceStatusEl);
}
_voiceStatusEl.textContent = "";
window.setTimeout(() => {
if (_voiceStatusEl) _voiceStatusEl.textContent = msg;
}, 30);
}
// Visually-hidden POLITE live region for the tool-call early paint // Visually-hidden POLITE live region for the tool-call early paint
// (tool_pending) so screen-reader users hear a committed call land — and that // (tool_pending) so screen-reader users hear a committed call land — and that
// they can Stop it — even though messagesEl is flipped to aria-live="off" // they can Stop it — even though messagesEl is flipped to aria-live="off"
// during the token streaming that immediately precedes the call. Polite (not // during the token streaming that immediately precedes the call. Polite (not
// assertive): a committed call is worth surfacing but isn't the action-required // assertive): a committed call is worth surfacing but isn't the action-required
// human gate, which keeps its own assertive announcement. Separate node from // human gate, which keeps its own assertive announcement. Separate region
// the voice region so the two never clobber each other. Single shared node; // from the voice one so the two never clobber each other.
// clear-then-set so repeated identical messages re-announce. const _toolStatus = makeAnnouncer();
let _toolStatusEl = null; // The wrapper earns its keep with the empty-message guard —
// _toolAnnounceText returns "" for a batch with no named tools.
function toolAnnounce(msg) { function toolAnnounce(msg) {
if (!msg) return; if (!msg) return;
if (!_toolStatusEl) { _toolStatus(msg);
_toolStatusEl = document.createElement("div");
_toolStatusEl.className = "sr-only";
_toolStatusEl.setAttribute("role", "status");
_toolStatusEl.setAttribute("aria-live", "polite");
document.body.appendChild(_toolStatusEl);
}
_toolStatusEl.textContent = "";
window.setTimeout(() => {
if (_toolStatusEl) _toolStatusEl.textContent = msg;
}, 30);
} }
// Terse SR summary for a committed tool batch: tool name(s) (capped at 3) + // Terse SR summary for a committed tool batch: tool name(s) (capped at 3) +
@@ -2082,12 +2065,7 @@ class Pane {
this.currentReasoningEl = null; this.currentReasoningEl = null;
} }
if (!this.currentAssistantEl) { if (!this.currentAssistantEl) {
this.currentAssistantEl = document.createElement("div"); this._newAssistantBubble();
this.currentAssistantEl.className = "msg assistant";
this.currentAssistantBodyEl = document.createElement("div");
this.currentAssistantBodyEl.className = "msg-body";
this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
this.messagesEl.appendChild(this.currentAssistantEl);
} }
this.contentBuffer += evt.text; this.contentBuffer += evt.text;
streamingRender(this.currentAssistantBodyEl, this.contentBuffer); streamingRender(this.currentAssistantBodyEl, this.contentBuffer);
@@ -2171,12 +2149,7 @@ class Pane {
this.currentReasoningEl = null; this.currentReasoningEl = null;
} }
if (!this.currentAssistantEl) { if (!this.currentAssistantEl) {
this.currentAssistantEl = document.createElement("div"); this._newAssistantBubble();
this.currentAssistantEl.className = "msg assistant";
this.currentAssistantBodyEl = document.createElement("div");
this.currentAssistantBodyEl.className = "msg-body";
this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
this.messagesEl.appendChild(this.currentAssistantEl);
} }
if (this.contentBuffer.length < evt.content.length) { if (this.contentBuffer.length < evt.content.length) {
this.contentBuffer = evt.content; this.contentBuffer = evt.content;
@@ -2733,28 +2706,38 @@ class Pane {
el.appendChild(bar); el.appendChild(bar);
} }
// The one creation path for live-streamed assistant bubbles (the
// content and in_progress_snapshot branches): bubble + body + the
// persistent copy action, with the streaming refs assigned as a unit
// so the two branches cannot drift.
_newAssistantBubble() {
this.currentAssistantEl = document.createElement("div");
this.currentAssistantEl.className = "msg assistant";
this.currentAssistantBodyEl = document.createElement("div");
this.currentAssistantBodyEl.className = "msg-body";
this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
this._addCopyAction(this.currentAssistantEl);
this.messagesEl.appendChild(this.currentAssistantEl);
}
// Every assistant bubble gets a persistent copy button at creation —
// O(1) per message, unlike the removed whole-transcript sweep this
// file's retry attach deliberately avoids (see
// _attachRetryToLastAssistant). The bar is shared with the transient
// retry / TTS buttons the holder mechanism adds and removes. Callers
// pass a JUST-CREATED bubble (both stream branches via
// _newAssistantBubble, and replay) — no dedup needed against an empty
// bar.
_addCopyAction(el) {
ensureMsgActionsBar(el).appendChild(buildMsgCopyButton(el));
}
_addRetryAction(el) { _addRetryAction(el) {
let bar = el.querySelector(".msg-actions"); const bar = ensureMsgActionsBar(el);
if (!bar) { bar.insertBefore(
bar = document.createElement("div"); buildMsgRetryButton(() => this._retryLast()),
bar.className = "msg-actions"; bar.firstChild,
bar.setAttribute("role", "toolbar"); );
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
const btn = document.createElement("button");
btn.className = "msg-action-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
const icon = document.createElement("span");
icon.className = "icon-retry";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", (e) => {
e.stopPropagation();
this._retryLast();
});
bar.insertBefore(btn, bar.firstChild);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -3029,15 +3012,11 @@ class Pane {
} }
_addTtsAction(el) { _addTtsAction(el) {
let bar = el.querySelector(".msg-actions"); const bar = ensureMsgActionsBar(el);
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
if (bar.querySelector(".msg-tts-btn")) return; // already added if (bar.querySelector(".msg-tts-btn")) return; // already added
// Inserted BEFORE the persistent copy button (below) so DOM order,
// tab order and visual order agree: [retry, tts, copy], with copy
// anchoring the corner on every bubble.
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.className = "msg-action-btn msg-tts-btn"; btn.className = "msg-action-btn msg-tts-btn";
btn.title = "Play response aloud"; btn.title = "Play response aloud";
@@ -3051,7 +3030,7 @@ class Pane {
e.stopPropagation(); e.stopPropagation();
this._playMessageTTS(el, btn); this._playMessageTTS(el, btn);
}); });
bar.appendChild(btn); bar.insertBefore(btn, bar.querySelector(".msg-copy-btn"));
} }
// Strip code blocks / inline code / rendered math so TTS doesn't read source // Strip code blocks / inline code / rendered math so TTS doesn't read source
@@ -3456,7 +3435,14 @@ class Pane {
const bodyEl = document.createElement("div"); const bodyEl = document.createElement("div");
bodyEl.className = "msg-body"; bodyEl.className = "msg-body";
el.appendChild(bodyEl); el.appendChild(bodyEl);
setMarkdown(bodyEl, msg.content); // Same render mechanism as the coordinator's history path: the
// finalize helper renders + post-renders AND stashes the raw
// markdown on the body for the copy affordance — replayed
// bubbles must copy identically to live-streamed ones. The
// copy action attaches BEFORE the render, matching the
// streaming path's attach-then-fill order.
this._addCopyAction(el);
streamingRenderFinalize(bodyEl, msg.content);
this.messagesEl.appendChild(el); this.messagesEl.appendChild(el);
lastToolBlock = null; lastToolBlock = null;
} }
@@ -3661,13 +3647,20 @@ class Pane {
} }
_attachRetryToLastAssistant() { _attachRetryToLastAssistant() {
// Remove the previous holder's action bar via the tracked ref — the old // Remove the previous holder's retry / TTS buttons via the tracked ref —
// whole-transcript ".msg.assistant .msg-actions" sweep was O(N) per // the old whole-transcript ".msg.assistant .msg-actions" sweep was O(N)
// busy→idle edge. At most one assistant bar exists (this method is its // per busy→idle edge. The bar itself stays: every assistant bubble owns
// only writer); a holder detached by a rebuild no-ops harmlessly. // one for its persistent copy button (_addCopyAction, at creation), and
// only the transient retry / TTS buttons move with the holder. At most
// one bubble carries them (this method is their only writer); a holder
// detached by a rebuild no-ops harmlessly.
if (this._retryHolderEl) { if (this._retryHolderEl) {
const oldBar = this._retryHolderEl.querySelector(".msg-actions"); const oldBar = findMsgActionsBar(this._retryHolderEl);
if (oldBar) oldBar.remove(); if (oldBar) {
oldBar
.querySelectorAll(".msg-retry-btn, .msg-tts-btn")
.forEach((b) => b.remove());
}
this._retryHolderEl = null; this._retryHolderEl = null;
} }
// Find the last assistant message with content and add retry. // Find the last assistant message with content and add retry.
+5
View File
@@ -176,6 +176,11 @@ export function buildMcpErrorEmbed(err, rawJson, onConsent) {
details.appendChild(summary); details.appendChild(summary);
const pre = document.createElement("pre"); const pre = document.createElement("pre");
pre.className = "tool-output"; pre.className = "tool-output";
// Focusable like every copyable block: where this card mounts inside a
// chat .msg-body the pre hosts the pointer copy affordance, and the
// keyboard copy path (copy_actions.js, Enter) acts on the FOCUSED
// block — and it is a horizontal scroll region regardless.
pre.setAttribute("tabindex", "0");
pre.textContent = tryPrettyJson(rawJson) || redactCredentials(rawJson); pre.textContent = tryPrettyJson(rawJson) || redactCredentials(rawJson);
details.appendChild(pre); details.appendChild(pre);
wrapper.appendChild(details); wrapper.appendChild(details);
+120 -16
View File
@@ -4,6 +4,10 @@
// globals). Window bridge at the bottom for the still-classic consumers. // globals). Window bridge at the bottom for the still-classic consumers.
import { escapeHtml } from "./utils.js"; import { escapeHtml } from "./utils.js";
// Side-effect import: the copy-to-clipboard affordances (floating block
// button + delegated listeners) ride the renderer so they land on every
// surface that renders markdown — no per-page wiring to drift.
import "./copy_actions.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Inline formatting // Inline formatting
@@ -377,8 +381,11 @@ function _renderMarkdownBody(text) {
/^([ \t]*)((?:[-*+]|\d+[.)])[ \t]+)?(```+)([^\s`]*)\n((?:(?!\3)[\s\S])*?)\3[ \t]*(?=\n|$)/gm, /^([ \t]*)((?:[-*+]|\d+[.)])[ \t]+)?(```+)([^\s`]*)\n((?:(?!\3)[\s\S])*?)\3[ \t]*(?=\n|$)/gm,
function (m, indent, marker, _open, lang, code) { function (m, indent, marker, _open, lang, code) {
var cssLang = _langToCssClass(lang); var cssLang = _langToCssClass(lang);
// tabindex=0: a fence is a horizontal scroll region (keyboard users
// must be able to reach and scroll it), and a focused block is what
// the keyboard copy path acts on (copy_actions.js, Enter).
codeBlocks.push( codeBlocks.push(
"<pre><code" + '<pre tabindex="0"><code' +
(cssLang ? ' class="language-' + escapeHtml(cssLang) + '"' : "") + (cssLang ? ' class="language-' + escapeHtml(cssLang) + '"' : "") +
">" + ">" +
// Strip the trailing newline plus any whitespace an indented close // Strip the trailing newline plus any whitespace an indented close
@@ -525,9 +532,22 @@ function _renderMarkdownBody(text) {
// The reverse edge case (math containing backticks, e.g. // The reverse edge case (math containing backticks, e.g.
// ``$$ \verb|`x`| $$``) is much rarer and KaTeX would reject // ``$$ \verb|`x`| $$``) is much rarer and KaTeX would reject
// the verbatim syntax anyway. // the verbatim syntax anyway.
// Raw-source twins for the inline-code / math sentinels, mirroring
// Raw twins: the table pass slices its data-md-source region from
// POST-masking text, so a cell's sentinels must be restored to the raw
// source the user wrote before the slice lands in an attribute — the
// rendered substitutes carry quotes/markup and are restored AFTER the
// table splice, i.e. inside the attribute value (the B-class breakout).
// Every mask pushes to its raw twin unconditionally, so the twins are
// index-aligned with the rendered arrays by construction — the cost is
// one string reference per span.
var inlineCodesRaw = [];
var mathBlocksRaw = [];
var inlineMathsRaw = [];
var inlineCodes = []; var inlineCodes = [];
text = text.replace(/`([^`\n]+)`/g, function (m, code) { text = text.replace(/`([^`\n]+)`/g, function (m, code) {
inlineCodes.push("<code>" + escapeHtml(code) + "</code>"); inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
inlineCodesRaw.push(m);
return "\x00IC" + (inlineCodes.length - 1) + "\x00"; return "\x00IC" + (inlineCodes.length - 1) + "\x00";
}); });
@@ -540,10 +560,12 @@ function _renderMarkdownBody(text) {
var mathBlocks = []; var mathBlocks = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) { text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true)); mathBlocks.push(renderLatex(tex.trim(), true));
mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00"; return "\x00MB" + (mathBlocks.length - 1) + "\x00";
}); });
text = text.replace(/\\\[([\s\S]+?)\\\]/g, function (m, tex) { text = text.replace(/\\\[([\s\S]+?)\\\]/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true)); mathBlocks.push(renderLatex(tex.trim(), true));
mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00"; return "\x00MB" + (mathBlocks.length - 1) + "\x00";
}); });
@@ -564,9 +586,33 @@ function _renderMarkdownBody(text) {
var inlineMaths = []; var inlineMaths = [];
text = text.replace(/\\\(([^\n]+?)\\\)/g, function (m, tex) { text = text.replace(/\\\(([^\n]+?)\\\)/g, function (m, tex) {
inlineMaths.push(renderLatex(tex.trim(), false)); inlineMaths.push(renderLatex(tex.trim(), false));
inlineMathsRaw.push(m);
return "\x00IM" + (inlineMaths.length - 1) + "\x00"; return "\x00IM" + (inlineMaths.length - 1) + "\x00";
}); });
// One ordered spec drives every raw-span restore: REVERSE mask order
// (IM, MB, then IC), because a later mask's span can swallow an
// earlier mask's sentinel into its raw twin (math wrapping inline
// code) — each pass re-exposes the sentinels the passes after it
// resolve. A new span mask gets a row here alongside its raw twin.
// Two restore sites: a table's data-md-source stash, and
// footnote-definition bodies before their recursive render.
var rawSpanPasses = [
[/\x00IM(\d+)\x00/g, inlineMathsRaw],
[/\x00MB(\d+)\x00/g, mathBlocksRaw],
[/\x00IC(\d+)\x00/g, inlineCodesRaw],
];
function restoreRawSpans(slice) {
if (slice.indexOf("\x00") === -1) return slice;
for (var rs = 0; rs < rawSpanPasses.length; rs++) {
slice = slice.replace(
rawSpanPasses[rs][0],
_restorer(rawSpanPasses[rs][1]),
);
}
return slice;
}
// Protect markdown tables (extract before line-by-line processing) // Protect markdown tables (extract before line-by-line processing)
var tableBlocks = []; var tableBlocks = [];
(function () { (function () {
@@ -616,8 +662,39 @@ function _renderMarkdownBody(text) {
dataRows.push(row); dataRows.push(row);
j++; j++;
} }
// The original pipe/alignment lines are unrecoverable from the
// rendered cells (trimmed, inline-rendered), so the copy affordance
// (copy_actions.js) needs them stashed here — the block's WHOLE
// raw source, by the same contract as message copy: the clipboard
// carries what was written, including source the render does not
// display (a row wider than the header renders truncated but
// copies whole). What is SHOWN is the render's decision; what is
// COPIED is the source.
//
// * The slice comes from POST-masking text: inline-code/math in
// cells are NUL sentinels whose global restores run AFTER the
// table restore — i.e. INSIDE this attribute — so they are
// resolved to their RAW sources first via restoreRawSpans
// (never into tlines: the rendered cells above keep theirs
// for the global passes). Every frame resolves its OWN span
// sentinels — footnote bodies are restored to raw before
// their recursive render — so the trailing strip is a pure
// backstop for a future recursion site that forgets that
// restore: an attribute must never carry a NUL sentinel,
// because the outer restores would splice rendered HTML into
// it after the fact.
//
// * The attribute escape is escapeHtml — the pipeline's one
// escaping helper — bound to safeMdSource for the
// double-quoted attribute value below.
var rawMdSource = restoreRawSpans(
tlines.slice(i, j).join("\n"),
).replace(/\x00[A-Z]{2}\d+\x00/g, "");
var safeMdSource = escapeHtml(rawMdSource);
var html = var html =
'<div class="table-wrap" tabindex="0" role="region" aria-label="Data table"><table>'; '<div class="table-wrap" tabindex="0" role="region" aria-label="Data table" data-md-source="' +
safeMdSource +
'"><table>';
html += "<thead><tr>"; html += "<thead><tr>";
for (var k = 0; k < hdrCells.length; k++) { for (var k = 0; k < hdrCells.length; k++) {
var align = aligns[k] || "left"; var align = aligns[k] || "left";
@@ -793,17 +870,19 @@ function _renderMarkdownBody(text) {
// Append footnote section if any definitions were collected. // Append footnote section if any definitions were collected.
// //
// Each definition body is rendered by a recursive renderMarkdown call. The // Each definition body is rendered by a recursive renderMarkdown call. The
// body was collected AFTER the inline-code/math passes, so it may carry // body was collected AFTER the inline-span masks, so it carries outer-scope
// outer-scope sentinels (e.g. `code` in a footnote -> a \x00IC\x00 sentinel). // span sentinels — restored to RAW source first (restoreRawSpans, the same
// The recursion can't resolve those against its own fresh, empty arrays, but // recipe the <details> pass uses for fenced bodies) so the recursion
// the restore guard (Fix 2) leaves the sentinel intact instead of emitting // re-masks the spans itself and its own table stashes resolve against its
// "undefined"; because this section is appended to `result` BEFORE the // own raw twins (an outer sentinel reaching a recursive stash would be
// restore passes below — whose inlineCodes/mathBlocks are still populated — // stripped as residue: silent loss in the copied source). BLOCK sentinels
// the OUTER restore resolves it, so inline code / math in a footnote renders // keep the original path: the recursion leaves a fence's \x00CB\x00 inert
// correctly. A FENCED block continuing a footnote definition works the same // (the restore guard emits the matched sentinel, never "undefined"), and
// way: the fence pass re-emits its 2-space indent before the sentinel, so // because this section is appended to `result` BEFORE the restore passes
// the continuation scan still collects it and the round-trip restores the // below — whose codeBlocks array is still populated — the OUTER restore
// code inside the footnote item. // resolves it, so a FENCED block continuing a footnote definition renders
// correctly (the fence pass re-emits its 2-space indent before the
// sentinel, so the continuation scan still collects it).
var fnKeys = Object.keys(footnoteDefs); var fnKeys = Object.keys(footnoteDefs);
if (fnKeys.length > 0) { if (fnKeys.length > 0) {
var fnHtml = var fnHtml =
@@ -818,7 +897,7 @@ function _renderMarkdownBody(text) {
"-def-" + "-def-" +
safeFid + safeFid +
'">' + '">' +
renderMarkdown(footnoteDefs[fid]) + renderMarkdown(restoreRawSpans(footnoteDefs[fid])) +
' <a href="#fn-' + ' <a href="#fn-' +
_fnScopeId + _fnScopeId +
"-ref-" + "-ref-" +
@@ -849,10 +928,17 @@ function _renderMarkdownBody(text) {
result = result.replace(/\x00DT(\d+)\x00/g, _restorer(detailsBlocks)); result = result.replace(/\x00DT(\d+)\x00/g, _restorer(detailsBlocks));
result = result.replace(/<p>\x00BQ(\d+)\x00<\/p>/g, _restorer(bqBlocks)); result = result.replace(/<p>\x00BQ(\d+)\x00<\/p>/g, _restorer(bqBlocks));
result = result.replace(/\x00BQ(\d+)\x00/g, _restorer(bqBlocks)); result = result.replace(/\x00BQ(\d+)\x00/g, _restorer(bqBlocks));
result = result.replace(/<p>\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks)); // Tables restore BEFORE the inline-span passes (MB/IC/IM): a table
result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks)); // CELL's rendered content still holds its inline sentinels, which only
// resolve if the table's HTML is already spliced into `result` when
// those passes run. (MB used to run first, so display math in a cell
// rendered as literal sentinel garbage.) The data-md-source attribute
// is immune to this ordering either way — it is built sentinel-free
// from the raw twins at emission time.
result = result.replace(/<p>\x00TB(\d+)\x00<\/p>/g, _restorer(tableBlocks)); result = result.replace(/<p>\x00TB(\d+)\x00<\/p>/g, _restorer(tableBlocks));
result = result.replace(/\x00TB(\d+)\x00/g, _restorer(tableBlocks)); result = result.replace(/\x00TB(\d+)\x00/g, _restorer(tableBlocks));
result = result.replace(/<p>\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks));
result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks));
result = result.replace(/\x00IC(\d+)\x00/g, _restorer(inlineCodes)); result = result.replace(/\x00IC(\d+)\x00/g, _restorer(inlineCodes));
result = result.replace(/\x00IM(\d+)\x00/g, _restorer(inlineMaths)); result = result.replace(/\x00IM(\d+)\x00/g, _restorer(inlineMaths));
@@ -1332,6 +1418,17 @@ function postRenderMermaid(containerEl) {
} }
var div = document.createElement("div"); var div = document.createElement("div");
div.setAttribute("data-mermaid-source", source); div.setAttribute("data-mermaid-source", source);
// Focusable like the other copyable blocks (pre / .table-wrap carry
// tabindex=0): a focused block is what the keyboard copy path acts on
// (copy_actions.js, Enter), and the container is a scrollable region
// (overflow-x) that keyboard users must be able to reach. Set at
// creation so the loading and error states are reachable too — the
// rendered-SVG apply replaces innerHTML, which leaves host attributes
// intact. role=region (not img: the error state carries readable
// message + source that AT must not flatten away).
div.setAttribute("tabindex", "0");
div.setAttribute("role", "region");
div.setAttribute("aria-label", "Diagram");
// Use cache.has (not truthiness) so a future cached value of // Use cache.has (not truthiness) so a future cached value of
// empty string / falsy SVG doesn't masquerade as a miss. // empty string / falsy SVG doesn't masquerade as a miss.
if (_mermaidSvgCache.has(source)) { if (_mermaidSvgCache.has(source)) {
@@ -1401,6 +1498,13 @@ export function reRenderAllMermaid() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function _streamingRenderApply(el, buffer) { function _streamingRenderApply(el, buffer) {
if (el._lastRenderedBuffer === buffer) return; if (el._lastRenderedBuffer === buffer) return;
// Unconditional copy-source stash (read by copy_actions.msgCopySource):
// BOTH exits below display content for this buffer — the catch paints it
// as plain text — so the stash must not ride _lastRenderedBuffer, whose
// stays-unset-on-throw semantics exist to make the next frame re-attempt
// the render (a copy reading it would get the last SUCCESSFUL frame's
// prefix while the bubble displays the full reply).
el._copySource = buffer;
try { try {
el.innerHTML = renderMarkdown(buffer); el.innerHTML = renderMarkdown(buffer);
} catch (e) { } catch (e) {
+93 -16
View File
@@ -2,9 +2,9 @@
ES module at the BOTTOM of the shared module graph: imports nothing, so ES module at the BOTTOM of the shared module graph: imports nothing, so
renderer.js / auth.js / kb.js / cards.js can import from here without renderer.js / auth.js / kb.js / cards.js can import from here without
cycles. The two helpers that call upward (setMarkdown renderer, cycles. The one helper that calls upward (exportWorkstreamDownload
exportWorkstreamDownload toast/auth) late-bind through window at CALL toast/auth) late-binds through window at CALL time instead importing
time instead importing them here would close an import cycle. here would close an import cycle.
The window bridge at the bottom keeps the still-classic consumers The window bridge at the bottom keeps the still-classic consumers
(console app.js / admin.js / governance.js, ui app.js, inline onclick=) (console app.js / admin.js / governance.js, ui app.js, inline onclick=)
@@ -149,6 +149,95 @@ export function makeEmptyState(text) {
return div; return div;
} }
// Build a screen-reader live-region announcer and return its announce
// function. The sr-only region is appended to document.body EAGERLY, at
// factory time: assistive tech only announces changes to a region that
// was ALREADY in the accessibility tree, so a region born lazily with
// its first message is silent exactly once. Announcing is clear-then-
// set on a short timer so repeated identical messages re-announce.
// Callers keep one announcer per concern (voice status, tool early
// paint, copy outcomes) so two announcements never clobber each other
// inside one region.
export function makeAnnouncer() {
const region = document.createElement("span");
region.className = "sr-only";
region.setAttribute("role", "status");
region.setAttribute("aria-live", "polite");
document.body.appendChild(region);
return function announce(text) {
region.textContent = "";
window.setTimeout(function () {
region.textContent = text;
}, 30);
};
}
// Copy text to the system clipboard; resolves true on success. The
// async Clipboard API exists only in secure contexts (HTTPS or
// localhost), and cluster nodes reached over plain HTTP on a LAN have
// no `navigator.clipboard` at all — those fall back to the legacy
// hidden-textarea + execCommand("copy") path. execCommand copies the
// textarea's selection, so the user's own selection and focus are
// captured first and restored after.
export async function copyTextToClipboard(text) {
const value = String(text == null ? "" : text);
if (window.isSecureContext && navigator.clipboard) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch (e) {
/* permission denied — the legacy path below still has a shot */
}
}
const prevFocus = document.activeElement;
const sel = document.getSelection();
const prevRanges = [];
if (sel) {
// cloneRange: getRangeAt returns LIVE ranges, and moving the
// selection into the shim textarea below can collapse them in
// place — a live ref would "restore" the collapsed range.
for (let i = 0; i < sel.rangeCount; i++) {
prevRanges.push(sel.getRangeAt(i).cloneRange());
}
}
const ta = document.createElement("textarea");
ta.value = value;
ta.setAttribute("readonly", "");
ta.setAttribute("aria-hidden", "true");
// This off-screen node briefly becomes the focused / hit-tested
// element mid-copy; delegated UI listeners (the copy affordance's
// pointer-dismissal rule) must be able to recognize and ignore the
// shim, or the copy gesture dismisses its own button mid-copy.
ta.setAttribute("data-clipboard-shim", "");
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.width = "1px";
ta.style.height = "1px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch (e) {
ok = false;
}
ta.remove();
if (sel) {
sel.removeAllRanges();
for (let i = 0; i < prevRanges.length; i++) sel.addRange(prevRanges[i]);
}
if (prevFocus && typeof prevFocus.focus === "function") {
try {
prevFocus.focus({ preventScroll: true });
} catch (e) {
/* focus restoration is best-effort */
}
}
return ok;
}
// Parse a *trusted* HTML string into DOM nodes and install them as // Parse a *trusted* HTML string into DOM nodes and install them as
// the new children of ``el``. Callers must guarantee the HTML was // the new children of ``el``. Callers must guarantee the HTML was
// produced by an escaping / sanitising pipeline (escapeHtml, // produced by an escaping / sanitising pipeline (escapeHtml,
@@ -161,18 +250,6 @@ export function setSafeHtml(el, html) {
el.replaceChildren(...Array.from(parsed.body.childNodes)); el.replaceChildren(...Array.from(parsed.body.childNodes));
} }
// Render markdown content into an element. renderMarkdown produces
// fully-escaped HTML (see renderer.js — every input runs through
// escapeHtml before any markdown ops; URLs are gated by an allow-list
// regex), so routing the result through setSafeHtml is safe as long
// as renderer.js is trusted. postRenderMarkdown finishes the job —
// hljs highlighting + mermaid SVG rendering for any code blocks the
// markdown emitted.
export function setMarkdown(el, content) {
setSafeHtml(el, window.renderMarkdown(content));
window.postRenderMarkdown(el);
}
// Download a workstream's conversation as OpenAI-shaped JSON. Hits // Download a workstream's conversation as OpenAI-shaped JSON. Hits
// GET {base}/v1/api/workstreams/{ws_id}/export, which streams a // GET {base}/v1/api/workstreams/{ws_id}/export, which streams a
// ``{"messages":[...]}`` body with a Content-Disposition attachment // ``{"messages":[...]}`` body with a Content-Disposition attachment
@@ -259,7 +336,7 @@ Object.assign(window, {
cssEscape, cssEscape,
makeKeyLabel, makeKeyLabel,
makeEmptyState, makeEmptyState,
copyTextToClipboard,
setSafeHtml, setSafeHtml,
setMarkdown,
exportWorkstreamDownload, exportWorkstreamDownload,
}); });