mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
+311
-11
@@ -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
|
||||
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
|
||||
interactive pane — mounts the REAL InteractivePane at real scroll geometry
|
||||
(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
|
||||
# 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">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -1007,16 +1048,9 @@ TASKAGENT_TEMPLATE = """<!doctype html>
|
||||
|
||||
const messages = document.getElementById("messages");
|
||||
try {
|
||||
// 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 = () => {};
|
||||
"""
|
||||
+ PANE_STUB_JS
|
||||
+ """
|
||||
const ev = (e) => pane.handleEvent(e);
|
||||
|
||||
// ?recall=1: exercise the RECALL path — replayHistory rebuilding the
|
||||
@@ -1165,6 +1199,266 @@ TASKAGENT_TEMPLATE = """<!doctype html>
|
||||
</body>
|
||||
</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")
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
symlink(pf / "shared", ROOT / "turnstone/shared_static")
|
||||
|
||||
@@ -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
@@ -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``,
|
||||
``shared_static/utils.js``, ``shared_static/auth.js``,
|
||||
``shared_static/kb.js``, ``coordinator.js`` chat entry,
|
||||
``console/static/app.js``): renderer output routes through
|
||||
``setMarkdown`` (or ``setSafeHtml`` for pre-baked HTML strings);
|
||||
``console/static/app.js``): renderer output routes through the
|
||||
streaming helpers or ``setSafeHtml`` (for pre-baked HTML strings);
|
||||
every other site uses ``createElement`` + ``textContent`` +
|
||||
``append`` / ``replaceChildren``. Missing escapes are
|
||||
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"
|
||||
+ "\n".join(f" line {n}: {line}" for n, line in offenders[:10])
|
||||
+ "\nUse DOM construction (createElement + textContent + "
|
||||
"append/replaceChildren) or route renderer output through "
|
||||
"setMarkdown() / setSafeHtml() in shared/utils.js."
|
||||
"append/replaceChildren) or route trusted HTML through "
|
||||
"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
|
||||
|
||||
|
||||
def test_shared_utils_defines_set_markdown_helper() -> None:
|
||||
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single
|
||||
audited entry point for rendering markdown content into a DOM
|
||||
element from ``app.js``. It parses ``renderMarkdown``'s output via
|
||||
``DOMParser`` (avoiding the unsafe sink entirely) and runs
|
||||
``postRenderMarkdown`` on the result. A refactor that drops or
|
||||
renames it would break the two interactive call sites silently at
|
||||
def test_shared_utils_defines_set_safe_html_helper() -> None:
|
||||
"""``setSafeHtml`` in ``shared/utils.js`` is the single audited entry
|
||||
point for installing trusted HTML strings into a DOM element outside
|
||||
renderer.js. It parses via ``DOMParser`` (avoiding the unsafe sink
|
||||
entirely); its markdown callers use it directly (coordinator
|
||||
``appendMsg``, preview.js), while renderer.js's streaming helpers
|
||||
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."""
|
||||
body = _UTILS_JS.read_text(encoding="utf-8")
|
||||
assert "function setMarkdown(el, content)" in body, (
|
||||
"shared/utils.js must define setMarkdown(el, content) — "
|
||||
"app.js routes both renderer-output sites through this helper."
|
||||
assert "function setSafeHtml(el, html)" in body, (
|
||||
"shared/utils.js must define setSafeHtml(el, html) — the "
|
||||
"sanctioned trusted-HTML installation chokepoint."
|
||||
)
|
||||
# The DOMParser path is what avoids the unsafe sink. The absence
|
||||
# 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
|
||||
# reviewer decision.
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
missing = set(SYSTEM_TURN_SOURCES) - labelled - {"compaction"}
|
||||
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
@@ -12,7 +12,7 @@ the rendered HTML for a sample input. The assertions check the
|
||||
resulting markup contains the expected ``<span class="katex">…</span>``
|
||||
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
|
||||
a shortcut: the mermaid harness pokes renderer-internal state
|
||||
(``_mermaidState``) that script evaluation exposes as a context global
|
||||
@@ -25,39 +25,20 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._js_harness_helpers import demodulize, node_skip
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js"
|
||||
_RENDERER_JS = _REPO_ROOT / "turnstone/shared_static/renderer.js"
|
||||
|
||||
|
||||
def _has_node() -> bool:
|
||||
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
|
||||
pytestmark = node_skip
|
||||
|
||||
|
||||
_HARNESS_TEMPLATE = """
|
||||
@@ -93,8 +74,8 @@ process.stdout.write(renderMarkdown(input));
|
||||
def _render(markdown: str) -> str:
|
||||
"""Render ``markdown`` through renderer.js + return the HTML."""
|
||||
harness = _HARNESS_TEMPLATE % {
|
||||
"utils_src": json.dumps(_demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
|
||||
"utils_src": json.dumps(demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(demodulize(_RENDERER_JS)),
|
||||
"input": json.dumps(markdown),
|
||||
}
|
||||
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:
|
||||
r"""\(...\) inside a fenced block must stay literal."""
|
||||
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
|
||||
|
||||
|
||||
@@ -470,8 +451,8 @@ _mermaidState = 'ready';
|
||||
def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]:
|
||||
"""Run a JS snippet against the mermaid-aware harness, return JSON output."""
|
||||
harness = _MERMAID_HARNESS_TEMPLATE % {
|
||||
"utils_src": json.dumps(_demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
|
||||
"utils_src": json.dumps(demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(demodulize(_RENDERER_JS)),
|
||||
"scenario": scenario_js,
|
||||
}
|
||||
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."""
|
||||
md = "```python\nprint('hi')\n```\n\nprose " + _NUL + "CB0" + _NUL + " end"
|
||||
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 _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)."""
|
||||
out = _render("Here is `inline` and a block:\n\n```py\nx = 1\n```")
|
||||
assert "<code>inline</code>" in out
|
||||
assert "<pre><code" in out
|
||||
assert '<pre tabindex="0"><code' in out
|
||||
assert "x = 1" 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>`` before the ``<pre>``. The unwrap removes the wrapping paragraph."""
|
||||
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 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."""
|
||||
out = _render("```text\nplain\n> quoted\nafter\n```")
|
||||
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 "> 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``.)"""
|
||||
out = _render("> ```\n> code\n> ```")
|
||||
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 _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
|
||||
deeper 4-space-indent case is pinned separately.)"""
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
code, never extracted into a real element."""
|
||||
out = _render("```html\n<details><summary>s</summary>x</details>\n```")
|
||||
assert "<pre><code" in out
|
||||
assert '<pre tabindex="0"><code' in out
|
||||
assert "<details>" 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
|
||||
|
||||
@@ -1795,7 +1780,7 @@ def test_code_block_in_details_renders_code() -> None:
|
||||
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>")
|
||||
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 _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."""
|
||||
md = "<details>\n<summary>s</summary>\n\n```html\n</details>\n```\n\n</details>"
|
||||
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 "</details>" in out, "example </details> should be literal code:\n" + out
|
||||
assert out.strip().startswith("<details><summary>s</summary>"), 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
|
||||
don't regress deeply-nested code samples to literal backticks."""
|
||||
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
|
||||
|
||||
|
||||
@@ -1866,7 +1853,9 @@ def test_fence_on_list_marker_line_renders_as_code() -> None:
|
||||
backticks + language tag as text."""
|
||||
for src in ["- ```py\n print(1)\n ```", "1. ```py\n print(1)\n ```"]:
|
||||
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 "```py" not in out, "raw fence backticks leaked as text:\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."""
|
||||
out = _render("- parent\n - ```py\n code\n ```")
|
||||
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
|
||||
|
||||
|
||||
@@ -1888,7 +1877,7 @@ def test_big_ordered_marker_fence_is_protected() -> None:
|
||||
protected — the marker alternation uses ``\d+``, matching the list pass,
|
||||
not a capped ``\d{1,9}`` that would leave the fence unprotected."""
|
||||
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
|
||||
|
||||
|
||||
@@ -1911,3 +1900,131 @@ def test_indented_details_is_extracted() -> None:
|
||||
out = _render(" <details><summary>x</summary>y</details>")
|
||||
assert "<details><summary>x</summary>" in out, "indented <details> not extracted:\n" + 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 "hi" & <b> | 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"
|
||||
|
||||
@@ -52,6 +52,7 @@ _ESM_BUNDLES = [
|
||||
_SHARED / "preview.js",
|
||||
_SHARED / "redact_credentials.js",
|
||||
_SHARED / "mcp_error.js",
|
||||
_SHARED / "copy_actions.js",
|
||||
]
|
||||
|
||||
# Sink scan: everything except renderer.js — the one sanctioned HTML-string
|
||||
@@ -74,6 +75,7 @@ _ESM_NO_VAR_BUNDLES = [
|
||||
_SHARED / "preview.js",
|
||||
_SHARED / "redact_credentials.js",
|
||||
_SHARED / "mcp_error.js",
|
||||
_SHARED / "copy_actions.js",
|
||||
]
|
||||
|
||||
# The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py``
|
||||
|
||||
@@ -3670,12 +3670,9 @@ function hideTokenCreatedModal() {
|
||||
|
||||
function copyCreatedToken() {
|
||||
if (!_lastCreatedToken) return;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(_lastCreatedToken).then(function () {
|
||||
showToast("Token copied to clipboard");
|
||||
});
|
||||
} else {
|
||||
// Fallback: select the text
|
||||
// Select the token text so a manual copy is one keystroke away — the
|
||||
// landing spot whenever no automatic path succeeded.
|
||||
function selectTokenFallback() {
|
||||
const el = document.getElementById("token-created-value");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
@@ -3684,6 +3681,33 @@ function copyCreatedToken() {
|
||||
sel.addRange(range);
|
||||
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
|
||||
|
||||
@@ -881,6 +881,12 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
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) {
|
||||
// Try parse JSON first — coordinator tool output is JSON-shaped.
|
||||
let parsed = null;
|
||||
@@ -901,7 +907,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
"<pre>" +
|
||||
'<pre tabindex="0">' +
|
||||
esc(redactCredentials(JSON.stringify(parsed, null, 2))) +
|
||||
"</pre>"
|
||||
);
|
||||
@@ -934,7 +940,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
" " + (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";
|
||||
setSafeHtml(body, html);
|
||||
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);
|
||||
_scheduleScroll();
|
||||
return el;
|
||||
@@ -5947,32 +5961,63 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
}
|
||||
|
||||
function _addRetryAction(el) {
|
||||
let bar = el.querySelector(".msg-actions");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
el.appendChild(bar);
|
||||
// Retry must render with ZERO module dependencies — it predates the
|
||||
// shared module lane and an operator on a degraded page (module fetch
|
||||
// failure) still needs regenerate. The shared builders are preferred
|
||||
// when bridged; the fallback twins ensureMsgActionsBar /
|
||||
// buildMsgRetryButton exactly (same classes — the .msg-retry-btn
|
||||
// selector is load-bearing for the teardown sweep — same
|
||||
// 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);
|
||||
}
|
||||
|
||||
function _refreshRetryButton() {
|
||||
const old = messagesEl.querySelectorAll(".msg.assistant .msg-actions");
|
||||
for (let i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
|
||||
// Remove only the transient retry buttons — a bar that still holds
|
||||
// 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
|
||||
// child is a .conv-batch construct); walk back past operator-context
|
||||
// rows first — the plain system bubble AND the structured watch-result /
|
||||
|
||||
@@ -857,18 +857,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — disable chat.css transitions.
|
||||
========================================================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg-actions,
|
||||
.msg-action-btn,
|
||||
.composer-attach,
|
||||
.composer-input,
|
||||
.composer-send {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
/* Reduced-motion overrides live in ONE block at the end of this file — a
|
||||
transition: none here would lose to the equal-specificity component rules
|
||||
declared below it. */
|
||||
|
||||
/* ==========================================================================
|
||||
Message primitive — chat-style single-column message block. Semantic
|
||||
@@ -1622,6 +1613,11 @@
|
||||
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.
|
||||
`(pointer: coarse)` narrows the match to true touch devices —
|
||||
`(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
|
||||
inline edit-in-place form. Shared by the interactive pane (ui/static) and
|
||||
the coordinator dashboard (console/static/coordinator); both load this file
|
||||
@@ -1728,6 +1724,148 @@
|
||||
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 */
|
||||
.msg-edit-form {
|
||||
display: flex;
|
||||
@@ -1797,7 +1935,11 @@
|
||||
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 {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
@@ -1874,8 +2016,20 @@
|
||||
.ws-status-bar.ws-sb-disconnected .ws-sb-turns {
|
||||
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) {
|
||||
.ws-status-bar {
|
||||
.ws-status-bar,
|
||||
.msg-actions,
|
||||
.msg-action-btn,
|
||||
.block-copy-btn,
|
||||
.composer-attach,
|
||||
.composer-input,
|
||||
.composer-send {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -55,7 +55,13 @@ import {
|
||||
} from "./composer_queue.js";
|
||||
import { StatusBar } from "./status_bar.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 {
|
||||
OVERFLOW_TRIP_COUNT,
|
||||
OVERFLOW_TRIP_WINDOW_MS,
|
||||
@@ -107,45 +113,22 @@ function getVoiceRoles(base) {
|
||||
|
||||
// Visually-hidden polite live region for voice status (recording / playback)
|
||||
// so screen-reader users perceive state changes otherwise conveyed only by
|
||||
// color/icon. Errors go through showToast (already a live region). Single
|
||||
// shared node; clear-then-set so repeated identical messages re-announce.
|
||||
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);
|
||||
}
|
||||
// color/icon. Errors go through showToast (already a live region).
|
||||
const voiceAnnounce = makeAnnouncer();
|
||||
|
||||
// Visually-hidden POLITE live region for the tool-call early paint
|
||||
// (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"
|
||||
// during the token streaming that immediately precedes the call. Polite (not
|
||||
// assertive): a committed call is worth surfacing but isn't the action-required
|
||||
// human gate, which keeps its own assertive announcement. Separate node from
|
||||
// the voice region so the two never clobber each other. Single shared node;
|
||||
// clear-then-set so repeated identical messages re-announce.
|
||||
let _toolStatusEl = null;
|
||||
// human gate, which keeps its own assertive announcement. Separate region
|
||||
// from the voice one so the two never clobber each other.
|
||||
const _toolStatus = makeAnnouncer();
|
||||
// The wrapper earns its keep with the empty-message guard —
|
||||
// _toolAnnounceText returns "" for a batch with no named tools.
|
||||
function toolAnnounce(msg) {
|
||||
if (!msg) return;
|
||||
if (!_toolStatusEl) {
|
||||
_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);
|
||||
_toolStatus(msg);
|
||||
}
|
||||
|
||||
// Terse SR summary for a committed tool batch: tool name(s) (capped at 3) +
|
||||
@@ -2082,12 +2065,7 @@ class Pane {
|
||||
this.currentReasoningEl = null;
|
||||
}
|
||||
if (!this.currentAssistantEl) {
|
||||
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.messagesEl.appendChild(this.currentAssistantEl);
|
||||
this._newAssistantBubble();
|
||||
}
|
||||
this.contentBuffer += evt.text;
|
||||
streamingRender(this.currentAssistantBodyEl, this.contentBuffer);
|
||||
@@ -2171,12 +2149,7 @@ class Pane {
|
||||
this.currentReasoningEl = null;
|
||||
}
|
||||
if (!this.currentAssistantEl) {
|
||||
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.messagesEl.appendChild(this.currentAssistantEl);
|
||||
this._newAssistantBubble();
|
||||
}
|
||||
if (this.contentBuffer.length < evt.content.length) {
|
||||
this.contentBuffer = evt.content;
|
||||
@@ -2733,28 +2706,38 @@ class Pane {
|
||||
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) {
|
||||
let bar = el.querySelector(".msg-actions");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
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);
|
||||
const bar = ensureMsgActionsBar(el);
|
||||
bar.insertBefore(
|
||||
buildMsgRetryButton(() => this._retryLast()),
|
||||
bar.firstChild,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -3029,15 +3012,11 @@ class Pane {
|
||||
}
|
||||
|
||||
_addTtsAction(el) {
|
||||
let bar = el.querySelector(".msg-actions");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
el.appendChild(bar);
|
||||
}
|
||||
const bar = ensureMsgActionsBar(el);
|
||||
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");
|
||||
btn.className = "msg-action-btn msg-tts-btn";
|
||||
btn.title = "Play response aloud";
|
||||
@@ -3051,7 +3030,7 @@ class Pane {
|
||||
e.stopPropagation();
|
||||
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
|
||||
@@ -3456,7 +3435,14 @@ class Pane {
|
||||
const bodyEl = document.createElement("div");
|
||||
bodyEl.className = "msg-body";
|
||||
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);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
@@ -3661,13 +3647,20 @@ class Pane {
|
||||
}
|
||||
|
||||
_attachRetryToLastAssistant() {
|
||||
// Remove the previous holder's action bar via the tracked ref — the old
|
||||
// whole-transcript ".msg.assistant .msg-actions" sweep was O(N) per
|
||||
// busy→idle edge. At most one assistant bar exists (this method is its
|
||||
// only writer); a holder detached by a rebuild no-ops harmlessly.
|
||||
// Remove the previous holder's retry / TTS buttons via the tracked ref —
|
||||
// the old whole-transcript ".msg.assistant .msg-actions" sweep was O(N)
|
||||
// per busy→idle edge. The bar itself stays: every assistant bubble owns
|
||||
// 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) {
|
||||
const oldBar = this._retryHolderEl.querySelector(".msg-actions");
|
||||
if (oldBar) oldBar.remove();
|
||||
const oldBar = findMsgActionsBar(this._retryHolderEl);
|
||||
if (oldBar) {
|
||||
oldBar
|
||||
.querySelectorAll(".msg-retry-btn, .msg-tts-btn")
|
||||
.forEach((b) => b.remove());
|
||||
}
|
||||
this._retryHolderEl = null;
|
||||
}
|
||||
// Find the last assistant message with content and add retry.
|
||||
|
||||
@@ -176,6 +176,11 @@ export function buildMcpErrorEmbed(err, rawJson, onConsent) {
|
||||
details.appendChild(summary);
|
||||
const pre = document.createElement("pre");
|
||||
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);
|
||||
details.appendChild(pre);
|
||||
wrapper.appendChild(details);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// globals). Window bridge at the bottom for the still-classic consumers.
|
||||
|
||||
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
|
||||
@@ -377,8 +381,11 @@ function _renderMarkdownBody(text) {
|
||||
/^([ \t]*)((?:[-*+]|\d+[.)])[ \t]+)?(```+)([^\s`]*)\n((?:(?!\3)[\s\S])*?)\3[ \t]*(?=\n|$)/gm,
|
||||
function (m, indent, marker, _open, lang, code) {
|
||||
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(
|
||||
"<pre><code" +
|
||||
'<pre tabindex="0"><code' +
|
||||
(cssLang ? ' class="language-' + escapeHtml(cssLang) + '"' : "") +
|
||||
">" +
|
||||
// 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.
|
||||
// ``$$ \verb|`x`| $$``) is much rarer and KaTeX would reject
|
||||
// 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 = [];
|
||||
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
|
||||
inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
|
||||
inlineCodesRaw.push(m);
|
||||
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
|
||||
});
|
||||
|
||||
@@ -540,10 +560,12 @@ function _renderMarkdownBody(text) {
|
||||
var mathBlocks = [];
|
||||
text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) {
|
||||
mathBlocks.push(renderLatex(tex.trim(), true));
|
||||
mathBlocksRaw.push(m);
|
||||
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
|
||||
});
|
||||
text = text.replace(/\\\[([\s\S]+?)\\\]/g, function (m, tex) {
|
||||
mathBlocks.push(renderLatex(tex.trim(), true));
|
||||
mathBlocksRaw.push(m);
|
||||
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
|
||||
});
|
||||
|
||||
@@ -564,9 +586,33 @@ function _renderMarkdownBody(text) {
|
||||
var inlineMaths = [];
|
||||
text = text.replace(/\\\(([^\n]+?)\\\)/g, function (m, tex) {
|
||||
inlineMaths.push(renderLatex(tex.trim(), false));
|
||||
inlineMathsRaw.push(m);
|
||||
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)
|
||||
var tableBlocks = [];
|
||||
(function () {
|
||||
@@ -616,8 +662,39 @@ function _renderMarkdownBody(text) {
|
||||
dataRows.push(row);
|
||||
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 =
|
||||
'<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>";
|
||||
for (var k = 0; k < hdrCells.length; k++) {
|
||||
var align = aligns[k] || "left";
|
||||
@@ -793,17 +870,19 @@ function _renderMarkdownBody(text) {
|
||||
// Append footnote section if any definitions were collected.
|
||||
//
|
||||
// Each definition body is rendered by a recursive renderMarkdown call. The
|
||||
// body was collected AFTER the inline-code/math passes, so it may carry
|
||||
// outer-scope sentinels (e.g. `code` in a footnote -> a \x00IC\x00 sentinel).
|
||||
// The recursion can't resolve those against its own fresh, empty arrays, but
|
||||
// the restore guard (Fix 2) leaves the sentinel intact instead of emitting
|
||||
// "undefined"; because this section is appended to `result` BEFORE the
|
||||
// restore passes below — whose inlineCodes/mathBlocks are still populated —
|
||||
// the OUTER restore resolves it, so inline code / math in a footnote renders
|
||||
// correctly. A FENCED block continuing a footnote definition works the same
|
||||
// way: the fence pass re-emits its 2-space indent before the sentinel, so
|
||||
// the continuation scan still collects it and the round-trip restores the
|
||||
// code inside the footnote item.
|
||||
// body was collected AFTER the inline-span masks, so it carries outer-scope
|
||||
// span sentinels — restored to RAW source first (restoreRawSpans, the same
|
||||
// recipe the <details> pass uses for fenced bodies) so the recursion
|
||||
// re-masks the spans itself and its own table stashes resolve against its
|
||||
// own raw twins (an outer sentinel reaching a recursive stash would be
|
||||
// stripped as residue: silent loss in the copied source). BLOCK sentinels
|
||||
// keep the original path: the recursion leaves a fence's \x00CB\x00 inert
|
||||
// (the restore guard emits the matched sentinel, never "undefined"), and
|
||||
// because this section is appended to `result` BEFORE the restore passes
|
||||
// below — whose codeBlocks array is still populated — the OUTER restore
|
||||
// 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);
|
||||
if (fnKeys.length > 0) {
|
||||
var fnHtml =
|
||||
@@ -818,7 +897,7 @@ function _renderMarkdownBody(text) {
|
||||
"-def-" +
|
||||
safeFid +
|
||||
'">' +
|
||||
renderMarkdown(footnoteDefs[fid]) +
|
||||
renderMarkdown(restoreRawSpans(footnoteDefs[fid])) +
|
||||
' <a href="#fn-' +
|
||||
_fnScopeId +
|
||||
"-ref-" +
|
||||
@@ -849,10 +928,17 @@ function _renderMarkdownBody(text) {
|
||||
result = result.replace(/\x00DT(\d+)\x00/g, _restorer(detailsBlocks));
|
||||
result = result.replace(/<p>\x00BQ(\d+)\x00<\/p>/g, _restorer(bqBlocks));
|
||||
result = result.replace(/\x00BQ(\d+)\x00/g, _restorer(bqBlocks));
|
||||
result = result.replace(/<p>\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks));
|
||||
result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks));
|
||||
// Tables restore BEFORE the inline-span passes (MB/IC/IM): a table
|
||||
// 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(/\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(/\x00IM(\d+)\x00/g, _restorer(inlineMaths));
|
||||
|
||||
@@ -1332,6 +1418,17 @@ function postRenderMermaid(containerEl) {
|
||||
}
|
||||
var div = document.createElement("div");
|
||||
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
|
||||
// empty string / falsy SVG doesn't masquerade as a miss.
|
||||
if (_mermaidSvgCache.has(source)) {
|
||||
@@ -1401,6 +1498,13 @@ export function reRenderAllMermaid() {
|
||||
// ---------------------------------------------------------------------------
|
||||
function _streamingRenderApply(el, buffer) {
|
||||
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 {
|
||||
el.innerHTML = renderMarkdown(buffer);
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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
|
||||
cycles. The two helpers that call upward (setMarkdown → renderer,
|
||||
exportWorkstreamDownload → toast/auth) late-bind through window at CALL
|
||||
time instead — importing them here would close an import cycle.
|
||||
cycles. The one helper that calls upward (exportWorkstreamDownload →
|
||||
toast/auth) late-binds through window at CALL time instead — importing
|
||||
here would close an import cycle.
|
||||
|
||||
The window bridge at the bottom keeps the still-classic consumers
|
||||
(console app.js / admin.js / governance.js, ui app.js, inline onclick=)
|
||||
@@ -149,6 +149,95 @@ export function makeEmptyState(text) {
|
||||
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
|
||||
// the new children of ``el``. Callers must guarantee the HTML was
|
||||
// produced by an escaping / sanitising pipeline (escapeHtml,
|
||||
@@ -161,18 +250,6 @@ export function setSafeHtml(el, html) {
|
||||
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
|
||||
// GET {base}/v1/api/workstreams/{ws_id}/export, which streams a
|
||||
// ``{"messages":[...]}`` body with a Content-Disposition attachment
|
||||
@@ -259,7 +336,7 @@ Object.assign(window, {
|
||||
cssEscape,
|
||||
makeKeyLabel,
|
||||
makeEmptyState,
|
||||
copyTextToClipboard,
|
||||
setSafeHtml,
|
||||
setMarkdown,
|
||||
exportWorkstreamDownload,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user