`;
- }).join('');
-
- this._updateCount(this.markers.length);
- },
-
- _updateCount(n) {
- const countEl = document.getElementById('marking-count');
- if (countEl) {
- countEl.textContent = n;
- countEl.style.display = n > 0 ? '' : 'none';
- }
- },
-
- cycleRole(number) {
- if (!this.active) return;
- const m = this.markers.find(x => x.number === number);
- if (!m) return;
-
- const idx = ROLE_CYCLE.indexOf(m.role);
- const next = ROLE_CYCLE[(idx + 1) % ROLE_CYCLE.length];
- m.role = next;
-
- this._send('marking_update', { markers: this.markers });
- this._redraw();
- this._renderList();
- },
-
- removeMarker(number) {
- this.markers = this.markers.filter(m => m.number !== number);
- // Renumber so labels stay 1..N
- this.markers.forEach((m, i) => m.number = i + 1);
-
- this._send('marking_update', { markers: this.markers });
-
- this._redraw();
- this._renderList();
- },
-
- clearAll() {
- if (!this.active) return;
- if (this.markers.length > 0 && !confirm('Clear all marked embryos?')) return;
-
- this.markers = [];
- this._send('marking_update', { markers: [] });
-
- this._redraw();
- this._renderList();
- },
-
- redetect() {
- if (!this.active) return;
- if (this.markers.length > 0 && !confirm('Recapture image and re-run SAM detection? Current markers will be replaced.')) return;
-
- this._send('marking_redetect', {});
-
- const instructions = document.getElementById('marking-instructions');
- if (instructions) {
- instructions.textContent = 'Recapturing and re-running detection…';
- }
- },
-
- done() {
- if (!this.active) return;
-
- if (this.markers.length === 0) {
- if (!confirm('No embryos marked. Finish anyway?')) return;
- }
-
- this._send('marking_done', { markers: this.markers });
-
- this.active = false;
- const counts = this.markers.reduce((acc, m) => {
- acc[m.role] = (acc[m.role] || 0) + 1;
- return acc;
- }, {});
- const summary = Object.entries(counts)
- .map(([r, n]) => `${n} ${r}`)
- .join(', ');
- const instructions = document.getElementById('marking-instructions');
- if (instructions) {
- instructions.textContent = `Marking complete — ${this.markers.length} embryo(s)${summary ? ': ' + summary : ''}.`;
- }
-
- document.querySelectorAll('.marking-actions .marking-action-btn').forEach(btn => btn.disabled = true);
-
- // Auto-switch back to monitoring after the user sees the
- // "marking complete" confirmation. Without this the marker
- // window stays put even after the agent has started a
- // timelapse, and the user has to manually switch tabs.
- setTimeout(() => {
- try { this.switchSubtab('monitoring'); } catch (_) { /* tabs may be gone */ }
- // Reset placeholder/active visibility so the next session
- // starts fresh on this tab.
- const placeholder = document.getElementById('marking-placeholder');
- const activeEl = document.getElementById('marking-active');
- if (placeholder) placeholder.style.display = '';
- if (activeEl) activeEl.style.display = 'none';
- this.markers = [];
- this._updateCount(0);
- this._redraw();
- this._renderList();
- }, 1500);
- },
-
- _send(type, payload) {
- if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
- state.ws.send(JSON.stringify({
- type,
- session_id: this.sessionId,
- ...payload,
- }));
- },
-
- // Switch between monitoring and marking subtabs
- switchSubtab(subtab) {
- document.querySelectorAll('.embryos-subtab').forEach(t => t.classList.remove('active'));
- const tab = document.querySelector(`.embryos-subtab[data-subtab="${subtab}"]`);
- if (tab) tab.classList.add('active');
-
- const monitoring = document.getElementById('embryos-monitoring');
- const marking = document.getElementById('embryos-marking');
- if (monitoring) monitoring.style.display = subtab === 'monitoring' ? '' : 'none';
- if (marking) marking.style.display = subtab === 'marking' ? '' : 'none';
- }
-};
-
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => MarkingManager.init());
-} else {
- MarkingManager.init();
-}
diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js
index c8634002..e09a9960 100644
--- a/gently/ui/web/static/js/operate.js
+++ b/gently/ui/web/static/js/operate.js
@@ -100,6 +100,29 @@ const OperateManager = (function () {
// has always done — a plain click must never silently narrow a timelapse
// from every subject to one, so the narrowing is a thing you say.
let _targetScope = 'all';
+
+ // ── AGENT-INITIATED MARKING ─────────────────────────────────────────────
+ // The agent can ask the operator to mark embryos on an image it pushes
+ // (server.start_marking_session -> a `marking_image` websocket frame, and
+ // it waits for `marking_done` keyed by session_id).
+ //
+ // That request used to land on a SECOND marking implementation —
+ // static/js/marking.js, 452 lines with its own canvas, its own hit-test
+ // and its own list, in the Embryos tab. So every fix to marking landed on
+ // one of two surfaces: the zoom, the display range, #105's hit-test and
+ // #126's aliasing all existed here and none of them there. Whether an
+ // operator got the corrected behaviour depended on who had asked them to
+ // mark.
+ //
+ // One surface now, two invocations. The pushed image is adapted into the
+ // frame shape this pane already understands, so all of that geometry
+ // applies unchanged.
+ let _markSession = null;
+
+ /** The µm/px this session declares, or the rig default. */
+ function pxBase() {
+ return _markSession ? _markSession.pixelSizeUm : undefined;
+ }
// Read back from the Light panel's device read, never remembered here.
const ledIsOpen = () => (SharedState.get('light') || {}).led === 'Open';
let _galvo = 0.0, _piezo = 50.0;
@@ -610,7 +633,7 @@ const OperateManager = (function () {
function markerToCanvas(m, r) {
const f = frameOf(_lastBottom), cap = stageOf(_lastBottom);
if (!f || !cap || !M) return null;
- const px = M.stageToFrame(m.stageX, m.stageY, f, cap);
+ const px = M.stageToFrame(m.stageX, m.stageY, f, cap, pxBase());
if (!px) return null;
return { cx: r.x + (px[0] / r.fw) * r.w, cy: r.y + (px[1] / r.fh) * r.h, px };
}
@@ -623,7 +646,7 @@ const OperateManager = (function () {
_embryos.forEach(emb => {
const xy = resolveXY(emb);
if (!xy) return;
- const px = M.stageToFrame(xy.x, xy.y, f, cap);
+ const px = M.stageToFrame(xy.x, xy.y, f, cap, pxBase());
if (!px) return;
out.push({ emb, cx: r.x + (px[0] / r.fw) * r.w, cy: r.y + (px[1] / r.fh) * r.h });
});
@@ -672,7 +695,11 @@ const OperateManager = (function () {
ctx.stroke();
ctx.fillStyle = colour;
ctx.font = '600 11px Inter Tight, sans-serif';
- ctx.fillText(String(i + 1), cx + 13, cy - 8);
+ // During an agent-initiated session each marker carries a role that
+ // goes back in the answer, so a reference must be distinguishable
+ // on the image and not only in the panel.
+ const tag = (_markSession && m.role === 'calibration') ? `${i + 1}·ref` : String(i + 1);
+ ctx.fillText(tag, cx + 13, cy - 8);
ctx.restore();
});
}
@@ -716,7 +743,7 @@ const OperateManager = (function () {
if (!cap) { toastFail('Stage position unknown — wait for the readout, then mark'); return; }
const fx = ((cxv - r.x) / r.w) * r.fw, fy = ((cyv - r.y) / r.h) * r.fh;
- const s = M && M.frameToStage(fx, fy, f, cap);
+ const s = M && M.frameToStage(fx, fy, f, cap, pxBase());
if (!s) { toastFail('Cannot place a marker without a stage position'); return; }
_markers.push({ stageX: s[0], stageY: s[1], source: 'manual' });
drawMarkers(); renderMarkCount();
@@ -758,6 +785,16 @@ const OperateManager = (function () {
detecting: _detecting,
startedAt: _detectStartedAt,
note: _markNote,
+ // Present only while the agent is waiting on an answer. The panel
+ // renders the pending markers and their roles from this, because
+ // the contract carries a role per marker.
+ session: _markSession ? {
+ pending: _markers.map((m, i) => ({
+ index: i,
+ role: m.role || _markSession.defaultRole,
+ source: m.source || 'manual',
+ })),
+ } : null,
});
}
@@ -834,7 +871,7 @@ const OperateManager = (function () {
cands.forEach(c => {
let sx = c.stage_x_um, sy = c.stage_y_um;
if ((sx == null || sy == null) && f && cap && M && c.pixel_x != null && c.pixel_y != null) {
- const s = M.frameToStage(c.pixel_x / f.downsample, c.pixel_y / f.downsample, f, cap);
+ const s = M.frameToStage(c.pixel_x / f.downsample, c.pixel_y / f.downsample, f, cap, pxBase());
if (s) { sx = s[0]; sy = s[1]; }
}
if (sx == null || sy == null) return;
@@ -869,7 +906,7 @@ const OperateManager = (function () {
// the localiser that will replace SAM. Pixel coords are projected
// from stage space against the frame being submitted.
const markers = _markers.map(m => {
- const px = (f && M) ? M.stageToFrame(m.stageX, m.stageY, f, cap) : null;
+ const px = (f && M) ? M.stageToFrame(m.stageX, m.stageY, f, cap, pxBase()) : null;
return {
stage_x_um: m.stageX, stage_y_um: m.stageY,
pixel_x: px ? px[0] : undefined, pixel_y: px ? px[1] : undefined,
@@ -1491,6 +1528,124 @@ const OperateManager = (function () {
el.textContent = bits.join(' · ');
}
+ /**
+ * The agent is asking the operator to mark embryos on an image it captured.
+ *
+ * Adapts the pushed image into the same payload shape a live bottom-camera
+ * frame has, so `frameOf`/`stageOf`/`drawMarkers`/`onCanvasClick` and the
+ * corrected hit-test all work on it with no special cases. The one thing
+ * that differs is the scale: the session declares its own `pixel_size_um`,
+ * which `pxBase()` threads into the geometry.
+ */
+ function onMarkingImage(d) {
+ if (!d || !d.image_b64) return;
+
+ _markSession = {
+ sessionId: d.session_id,
+ defaultRole: d.default_role || 'test',
+ pixelSizeUm: d.pixel_size_um || undefined,
+ };
+
+ // A pushed still, in the shape a live frame arrives in. `mime` matters:
+ // start_marking_session sends PNG, the camera stream sends JPEG.
+ _lastBottom = {
+ t: Date.now(),
+ shape: [d.height, d.width],
+ downsample: 1,
+ stage_position: [d.stage_x_um != null ? d.stage_x_um : 0,
+ d.stage_y_um != null ? d.stage_y_um : 0],
+ mime: 'image/png',
+ jpeg_b64: d.image_b64,
+ };
+
+ const f = frameOf(_lastBottom), cap = stageOf(_lastBottom);
+ // Initial markers arrive in PIXEL coordinates; this pane keeps markers
+ // in stage µm so they stay attached to the sample under zoom and stage
+ // motion. Convert once, here, at the boundary.
+ _markers = (d.initial_markers || []).map(m => {
+ const st = (f && cap && M) ? M.frameToStage(m.pixelX, m.pixelY, f, cap, pxBase()) : null;
+ return st ? {
+ stageX: st[0], stageY: st[1],
+ source: m.source || 'sam',
+ role: m.role || _markSession.defaultRole,
+ embryo_id: m.embryo_id || null,
+ confidence: m.confidence != null ? m.confidence : null,
+ } : null;
+ }).filter(Boolean);
+
+ // Bring the operator to the surface rather than expecting them to find
+ // it. The old implementation did this too, and it is right: the agent
+ // is blocked waiting on an answer.
+ if (typeof switchTab === 'function') switchTab('devices');
+ showPane('bottom');
+ setImg('op-img-bottom', 'op-ph-bottom', _lastBottom);
+ drawMarkers();
+ renderMarkCount();
+ setDetectNote(_markers.length
+ ? `${_markers.length} detected — the agent is waiting. Adjust them, set roles, then Done.`
+ : 'The agent is waiting for you to mark the embryos. Click each one, then Done.');
+ }
+
+ /** Answer the agent. Markers go back in pixel coordinates, with roles. */
+ function finishMarkingSession() {
+ if (!_markSession) return;
+ const f = frameOf(_lastBottom), cap = stageOf(_lastBottom);
+ const markers = _markers.map((m, i) => {
+ const px = (f && cap && M) ? M.stageToFrame(m.stageX, m.stageY, f, cap, pxBase()) : null;
+ return px ? {
+ number: i + 1,
+ pixelX: Math.round(px[0]),
+ pixelY: Math.round(px[1]),
+ role: m.role || _markSession.defaultRole,
+ source: m.source || 'manual',
+ embryo_id: m.embryo_id || null,
+ confidence: m.confidence != null ? m.confidence : null,
+ timestamp: new Date().toISOString(),
+ } : null;
+ }).filter(Boolean);
+
+ const sent = sendWs({ type: 'marking_done', session_id: _markSession.sessionId, markers });
+ if (!sent) {
+ toastFail('Not connected — the agent did not get your marks');
+ return;
+ }
+ const n = markers.length;
+ _markSession = null;
+ _markers = [];
+ drawMarkers();
+ renderMarkCount();
+ setDetectNote(`Marking complete — ${n} embryo${n === 1 ? '' : 's'} sent to the agent.`);
+ publishMarking();
+ }
+
+ /** Ask the agent to recapture and re-detect. */
+ function redetectMarkingSession() {
+ if (!_markSession) return;
+ if (!sendWs({ type: 'marking_redetect', session_id: _markSession.sessionId })) {
+ toastFail('Not connected — could not ask for a re-detect');
+ return;
+ }
+ setDetectNote('Asked the agent to recapture and re-detect…');
+ }
+
+ /** Cycle one pending marker between subject and reference. */
+ function cycleMarkerRole(index) {
+ const m = _markers[index];
+ if (!m || !_markSession) return;
+ m.role = m.role === 'calibration' ? 'test' : 'calibration';
+ drawMarkers();
+ publishMarking();
+ }
+
+ // The marking contract is a websocket request/response keyed on
+ // session_id, not an HTTP call — the agent is blocked on `complete`.
+ function sendWs(payload) {
+ const ws = (typeof state !== 'undefined' && state) ? state.ws : null;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ ws.send(JSON.stringify(payload));
+ return true;
+ }
+
// ══ EVENTS ══════════════════════════════════════════════════════════════
function onBottomFrame(p) {
// Bail when hidden, or a hidden Operate keeps base64-decoding every
@@ -1643,6 +1798,8 @@ const OperateManager = (function () {
// Server-pushed stills (focus montages today). websocket.js emits
// this for every image; onPushedImage takes the kinds this pane owns.
ClientEventBus.on('IMAGE_RECEIVED', onPushedImage);
+ // The agent asking for marks. One surface, two invocations.
+ ClientEventBus.on('MARKING_IMAGE', onMarkingImage);
ClientEventBus.on('DEVICE_STATE_UPDATE', p => {
const pos = p && p.positions;
if (!pos) return;
@@ -1722,6 +1879,10 @@ const OperateManager = (function () {
detect: () => runDetect(),
register: () => confirmMarks(),
clear: () => clearMarks(),
+ // Session verbs — only meaningful while the agent is waiting.
+ done: () => finishMarkingSession(),
+ redetect: () => redetectMarkingSession(),
+ cycleRole: i => cycleMarkerRole(Number(i)),
},
};
})();
diff --git a/gently/ui/web/static/js/panels/marking.js b/gently/ui/web/static/js/panels/marking.js
index eee06838..db347446 100644
--- a/gently/ui/web/static/js/panels/marking.js
+++ b/gently/ui/web/static/js/panels/marking.js
@@ -111,12 +111,51 @@ const MarkingPanel = (() => {
>Clear
+ ${session(s)}
${s.note ? `
${escape(s.note)}
` : ''}
`;
wire(el);
});
}
+ /**
+ * The agent-initiated session, when there is one.
+ *
+ * Present only while the agent is waiting — this is the one part of the
+ * panel that is a transient condition rather than a standing control, so it
+ * seats and retires itself (PANELS.md rule 6).
+ *
+ * The per-marker role list exists because the contract needs it:
+ * `marking_done` carries a role per marker and the waiting agent reads
+ * them, so the operator has to be able to say which of these is a
+ * reference before answering. Registered embryos get their roles in the
+ * Acquisition roster; these are not registered yet.
+ */
+ function session(s) {
+ if (!s.session) return '';
+ const rows = (s.session.pending || []).map(m => {
+ const ref = m.role === 'calibration';
+ return `
+ ${m.index + 1}
+ ${escape(m.source)}
+
+
`;
+ }).join('');
+
+ return `
+
The agent is waiting
+ ${rows || '
Nothing marked yet — click each embryo on the image.
'}
+
+
+
+
+
`;
+ }
+
function escape(t) {
return String(t).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
@@ -128,7 +167,8 @@ const MarkingPanel = (() => {
const v = verbs();
if (!v) return;
const fn = v[b.dataset.act];
- if (typeof fn === 'function') fn();
+ // `data-index` is only present on the per-marker role toggles.
+ if (typeof fn === 'function') fn(b.dataset.index);
};
});
}
diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js
index 8b0fca54..4d94c65d 100644
--- a/gently/ui/web/static/js/websocket.js
+++ b/gently/ui/web/static/js/websocket.js
@@ -131,14 +131,16 @@ function handleMessage(msg) {
} else if (msg.type === 'timelapse_state') {
ClientEventBus.emit('TIMELAPSE_STATE', msg.data);
} else if (msg.type === 'marking_image') {
- // Server is requesting embryo marking
- if (typeof MarkingManager !== 'undefined') {
- MarkingManager.handleMarkingImage(msg.data);
- // Auto-switch to marking subtab
- MarkingManager.switchSubtab('marking');
- // Switch to embryos tab if not already there
- if (state.tab !== 'embryos') switchTab('embryos');
- }
+ // The agent is asking the operator to mark embryos on an image it
+ // captured, and is blocked until `marking_done` comes back.
+ //
+ // This used to drive a second marking implementation in the Embryos
+ // tab (static/js/marking.js) with its own canvas and hit-test, so every
+ // improvement to marking landed on only one of two surfaces. It goes to
+ // the Operate bottom-camera pane now — the same surface an operator
+ // marks on unprompted, which means the agent's request inherits the
+ // zoom, the display range and the corrected hit-test.
+ ClientEventBus.emit('MARKING_IMAGE', msg.data);
} else if (msg.type === 'open_volume') {
// The agent asked us to open the in-browser volume viewer — the
// web-native replacement for the old desktop napari window.
diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html
index 3f9e830f..5394a006 100644
--- a/gently/ui/web/templates/index.html
+++ b/gently/ui/web/templates/index.html
@@ -310,14 +310,10 @@
Calibration
-
-
-
Monitoring
-
- Marking
- 0
-
-
+
@@ -372,40 +368,6 @@
Embryo Monitoring
-
-
-
-
-
📍
-
No marking session active
-
Start a multi-embryo calibration from the copilot to begin marking embryo positions.
-
-
-
-
-
-
- Click on each embryo center.
-
-
-
-
-
-
-
-
-
-
-
-
-
Marked Embryos
-
-
No embryos marked yet
-
-
-
-
-
@@ -1478,7 +1440,6 @@
Acquired
-
diff --git a/tests/test_one_marking_surface.py b/tests/test_one_marking_surface.py
new file mode 100644
index 00000000..93835ee4
--- /dev/null
+++ b/tests/test_one_marking_surface.py
@@ -0,0 +1,123 @@
+"""There is one marking surface, invoked two ways.
+
+There were two complete implementations. `static/js/marking.js` — 452 lines
+with its own canvas, its own hit-test, its own marker list — lived in the
+Embryos tab behind a "Marking" subtab, and `websocket.js` switched the operator
+into it when the agent sent a `marking_image` frame. `operate.js` had the
+other, on the bottom-camera pane, for an operator marking unprompted.
+
+So every improvement to marking landed on exactly one of them. The zoom, the
+display range, #105's hit-test radius and #126's roster aliasing were all on
+the Operate side; none of it existed on the agent's side. Whether an operator
+got the corrected behaviour depended on who had asked them to mark.
+
+The agent's request now lands on the Operate pane. The pushed image is adapted
+into the payload shape a live camera frame already has, so the existing
+geometry applies to it unchanged.
+
+WHAT MUST NOT BREAK
+
+The contract is a websocket request/response, not an HTTP call — the agent
+blocks on `session["complete"]` in `routes/websocket.py` and reads a role per
+marker out of the answer. So the reply must carry `marking_done` with the
+session id and pixel coordinates, and per-marker roles must remain settable
+before it is sent.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+WEB = Path(__file__).resolve().parents[1] / "gently" / "ui" / "web"
+JS = WEB / "static" / "js"
+INDEX = WEB / "templates" / "index.html"
+
+
+def test_the_second_implementation_is_gone() -> None:
+ assert not (JS / "marking.js").exists(), (
+ "static/js/marking.js is back — a second marking surface means every "
+ "fix lands on one of two, and which one an operator gets depends on who "
+ "asked them to mark"
+ )
+ html = INDEX.read_text(encoding="utf-8")
+ assert "MarkingManager" not in html
+ assert 'id="embryos-marking"' not in html
+ # The old path specifically. `panels/marking.js` is the panel and stays —
+ # the two files having near-identical names was itself part of the mess.
+ assert "/static/js/marking.js" not in html
+
+
+def test_the_agent_request_reaches_the_operate_surface() -> None:
+ ws = (JS / "websocket.js").read_text(encoding="utf-8")
+ assert "MARKING_IMAGE" in ws, "marking_image no longer reaches any surface"
+ assert "MarkingManager" not in ws
+
+ src = (JS / "operate.js").read_text(encoding="utf-8")
+ assert "ClientEventBus.on('MARKING_IMAGE'" in src
+ assert "function onMarkingImage(d)" in src
+
+
+def test_the_pushed_image_is_adapted_not_special_cased() -> None:
+ """It becomes a frame, so the existing geometry applies to it unchanged.
+
+ That is the whole point: the agent's request inherits the zoom, the
+ corrected hit-test and the display range because it is the same surface,
+ not because any of them were reimplemented.
+ """
+ src = (JS / "operate.js").read_text(encoding="utf-8")
+ body = src[src.index("function onMarkingImage(d)") :]
+ body = body[: body.index("\n }")]
+ # The live-frame payload shape: shape/downsample/stage_position/jpeg_b64.
+ for key in ("shape:", "downsample:", "stage_position:", "jpeg_b64:"):
+ assert key in body, f"the adapted frame is missing {key}"
+ # PNG, not JPEG — start_marking_session sends PNG.
+ assert "image/png" in body
+
+
+def test_the_session_declares_its_own_scale() -> None:
+ """`pixel_size_um` is a session parameter, not the rig default."""
+ src = (JS / "operate.js").read_text(encoding="utf-8")
+ assert "function pxBase()" in src
+ assert "pixelSizeUm" in src
+ # Threaded into every geometry call, or a session with a different scale
+ # would silently place markers wrong.
+ assert src.count("pxBase()") >= 5, (
+ "pxBase is not threaded through the geometry — a session declaring a "
+ "different µm/px would place markers at the rig default instead"
+ )
+
+
+def test_the_answer_keeps_the_contract() -> None:
+ src = (JS / "operate.js").read_text(encoding="utf-8")
+ body = src[src.index("function finishMarkingSession()") :]
+ body = body[: body.index("\n }")]
+ assert "'marking_done'" in body
+ assert "session_id" in body
+ # Back out to pixels: the server and the agent speak pixel coordinates.
+ assert "stageToFrame" in body
+ for field in ("number:", "pixelX:", "pixelY:", "role:", "source:"):
+ assert field in body, f"the answer is missing {field}"
+
+
+def test_per_marker_roles_stay_settable() -> None:
+ """`marking_done` carries a role per marker and the agent reads them.
+
+ Registered embryos get roles in the Acquisition roster; these are not
+ registered yet, so the session needs its own way to say which is a
+ reference.
+ """
+ src = (JS / "operate.js").read_text(encoding="utf-8")
+ assert "function cycleMarkerRole(index)" in src
+ assert "cycleRole:" in src, "the panel cannot reach the role toggle"
+
+ panel = (JS / "panels" / "marking.js").read_text(encoding="utf-8")
+ assert 'data-act="cycleRole"' in panel
+ assert "data-index=" in panel
+
+
+def test_the_session_ui_is_present_only_while_the_agent_waits() -> None:
+ """A standing "the agent is waiting" panel would be a lie most of the time."""
+ panel = (JS / "panels" / "marking.js").read_text(encoding="utf-8")
+ body = panel[panel.index("function session(s)") :]
+ body = body[: body.index("\n }")]
+ assert "if (!s.session) return ''" in body