-
${this.escapeHtml(s.name || s.session_id)}
+
+
${this.escapeHtml(s.name || s.session_id)}${s.active ? ' active' : ''}
${this.formatDate(s.created_at)}
${s.embryo_count ? `${s.embryo_count} embryo${s.embryo_count !== 1 ? 's' : ''}` : ''}
${s.description ? `
${this.escapeHtml(s.description)}
` : ''}
+ ${s.active ? '' : `
`}
`).join('');
},
+ async resumeSession(sessionId) {
+ if (!confirm('Switch the live agent to this session?\nThe current session is saved first.')) return;
+ try {
+ const resp = await fetch(`/api/sessions/${sessionId}/resume`, { method: 'POST' });
+ if (resp.ok) {
+ // Server broadcasts session_changed to reload all clients; we
+ // navigate home as well so the operator lands on the new session.
+ window.location.href = '/';
+ } else {
+ const d = await resp.json().catch(() => ({}));
+ alert('Resume failed: ' + (d.detail || ('HTTP ' + resp.status)));
+ }
+ } catch (e) {
+ alert('Resume failed: ' + e);
+ }
+ },
+
renderSessionContent() {
const content = document.getElementById('session-content');
const s = this.currentSession;
diff --git a/gently/ui/web/static/js/settings.js b/gently/ui/web/static/js/settings.js
index 66558731..2ade33e2 100644
--- a/gently/ui/web/static/js/settings.js
+++ b/gently/ui/web/static/js/settings.js
@@ -39,23 +39,78 @@ const SettingsManager = {
config: null,
- init() {
+ async init() {
+ this.serverDefaults = await this.fetchServerDefaults();
this.config = this.load();
this.populateForm();
this.setupNavigation();
this.setupListeners();
+ this.setupDefaultsBar();
+ },
+
+ async fetchServerDefaults() {
+ try {
+ const res = await fetch('/api/config/dashboard-defaults');
+ if (!res.ok) return {};
+ const d = await res.json();
+ return (d && typeof d === 'object') ? d : {};
+ } catch (e) { return {}; }
},
load() {
+ // Effective config = hardcoded defaults < rig-wide server defaults < this browser's localStorage.
+ const base = this.deepMerge(this.defaults, this.serverDefaults || {});
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
- return this.deepMerge(this.defaults, JSON.parse(stored));
+ return this.deepMerge(base, JSON.parse(stored));
}
} catch (e) {
console.warn('Failed to load settings:', e);
}
- return { ...this.defaults };
+ return base;
+ },
+
+ setupDefaultsBar() {
+ const byId = (id) => document.getElementById(id);
+ const saveDef = byId('pref-save-defaults');
+ if (saveDef) saveDef.addEventListener('click', async () => {
+ try {
+ const res = await fetch('/api/config/dashboard-defaults', {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(this.config),
+ });
+ if (res.status === 401 || res.status === 403) { this.showSaveStatus('Need control'); return; }
+ this.showSaveStatus(res.ok ? 'Saved as rig defaults' : 'Failed to save defaults');
+ } catch (e) { this.showSaveStatus('Failed to save defaults'); }
+ });
+ const reset = byId('pref-reset');
+ if (reset) reset.addEventListener('click', () => {
+ if (!confirm("Clear this browser's dashboard prefs and use the rig defaults?")) return;
+ localStorage.removeItem(this.STORAGE_KEY);
+ location.reload();
+ });
+ const exp = byId('pref-export');
+ if (exp) exp.addEventListener('click', () => {
+ const blob = new Blob([JSON.stringify(this.config, null, 2)], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob); a.download = 'gently-dashboard-prefs.json';
+ a.click(); URL.revokeObjectURL(a.href);
+ });
+ const imp = byId('pref-import'), impFile = byId('pref-import-file');
+ if (imp && impFile) {
+ imp.addEventListener('click', () => impFile.click());
+ impFile.addEventListener('change', async () => {
+ const file = impFile.files[0]; if (!file) return;
+ try {
+ const obj = JSON.parse(await file.text());
+ this.config = this.deepMerge(this.defaults, obj);
+ this.save(); this.populateForm();
+ this.showSaveStatus('Imported');
+ } catch (e) { this.showSaveStatus('Import failed: invalid JSON'); }
+ impFile.value = '';
+ });
+ }
},
save() {
@@ -177,7 +232,14 @@ const SettingsManager = {
const content = document.getElementById('settings-content');
if (!content) return;
- content.addEventListener('change', () => this.readFormAndSave());
+ // Ignore the server-backed hardware sections — they own their own
+ // handlers (ThermalizerSettings) and must not trigger the localStorage
+ // save or its "Settings saved" toast.
+ const isHardware = (t) => t && t.closest && t.closest('#section-thermalizer, #section-effective');
+ content.addEventListener('change', (e) => {
+ if (isHardware(e.target)) return;
+ this.readFormAndSave();
+ });
content.addEventListener('input', (e) => {
// Live update for range sliders
if (e.target.id === 'cfg-imageSplitRatio') {
@@ -233,3 +295,248 @@ const SettingsManager = {
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => SettingsManager.init());
+
+
+/**
+ * ThermalizerSettings — server-backed hardware config, isolated from the
+ * localStorage SettingsManager above. Reads/writes the ACUITYnano connection
+ * via /api/devices/temperature/config{,/test}. Mock backend is dev-only
+ * (revealed via ?dev=1 or localStorage 'gently-dev').
+ */
+const ThermalizerSettings = {
+ el(id) { return document.getElementById(id); },
+ devMode() {
+ try {
+ return new URLSearchParams(location.search).has('dev')
+ || localStorage.getItem('gently-dev') === '1';
+ } catch (_) { return false; }
+ },
+
+ async init() {
+ if (!this.el('section-thermalizer')) return;
+ if (this.devMode()) {
+ const m = document.querySelector('.th-mock-opt');
+ if (m) m.style.display = '';
+ }
+ // backend radio toggles the field groups
+ document.querySelectorAll('input[name="th-backend"]').forEach(r =>
+ r.addEventListener('change', () => this.applyBackendVisibility(r.value)));
+ const test = this.el('th-test'), apply = this.el('th-apply');
+ if (test) test.addEventListener('click', () => this.test());
+ if (apply) apply.addEventListener('click', () => this.apply());
+ await this.load();
+ await this.loadEffective();
+ },
+
+ applyBackendVisibility(backend) {
+ const s = this.el('th-serial'), m = this.el('th-mqtt');
+ if (s) s.style.display = backend === 'serial' ? '' : 'none';
+ if (m) m.style.display = backend === 'mqtt' ? '' : 'none';
+ },
+
+ setForm(cfg) {
+ cfg = cfg || {};
+ const backend = cfg.backend || 'serial';
+ const radio = document.querySelector(`input[name="th-backend"][value="${backend}"]`);
+ if (radio) radio.checked = true;
+ this.applyBackendVisibility(backend);
+ const set = (id, v) => { const e = this.el(id); if (e && v != null) e.value = v; };
+ set('th-com', cfg.com_port); set('th-baud', cfg.baud_rate);
+ set('th-broker', cfg.broker); set('th-port', cfg.port); set('th-user', cfg.user);
+ set('th-stabilize', cfg.stabilize_timeout);
+ const pel = this.el('th-peltier'); if (pel) pel.checked = !!cfg.feedback_peltier;
+ // password intentionally left blank (write-only)
+ },
+
+ readForm() {
+ const backend = (document.querySelector('input[name="th-backend"]:checked') || {}).value || 'serial';
+ const cfg = { backend };
+ const num = id => { const v = this.el(id) && this.el(id).value; return v === '' || v == null ? null : Number(v); };
+ const str = id => { const v = this.el(id) && this.el(id).value; return v == null ? '' : v.trim(); };
+ if (backend === 'serial') {
+ cfg.com_port = str('th-com');
+ if (num('th-baud') != null) cfg.baud_rate = num('th-baud');
+ } else if (backend === 'mqtt') {
+ if (str('th-broker')) cfg.broker = str('th-broker');
+ if (num('th-port') != null) cfg.port = num('th-port');
+ if (str('th-user')) cfg.user = str('th-user');
+ const pw = this.el('th-pass') && this.el('th-pass').value;
+ if (pw) cfg.password = pw; // blank = keep stored (server preserves)
+ }
+ if (num('th-stabilize') != null) cfg.stabilize_timeout = num('th-stabilize');
+ cfg.feedback_peltier = !!(this.el('th-peltier') && this.el('th-peltier').checked);
+ return cfg;
+ },
+
+ renderStatus(d) {
+ const el = this.el('th-status'); if (!el) return;
+ if (!d || d.available === false) { el.textContent = 'Controller not available (device layer offline or no thermalizer configured).'; return; }
+ const st = d.state || {};
+ const parts = [];
+ if (d.live_backend) parts.push(`backend: ${d.live_backend}`);
+ if (st.temperature_c != null) parts.push(`water: ${st.temperature_c} °C`);
+ if (st.setpoint_c != null) parts.push(`setpoint: ${st.setpoint_c} °C`);
+ if (st.state) parts.push(st.state);
+ el.textContent = parts.length ? parts.join(' · ') : 'active';
+ },
+
+ async load() {
+ try {
+ const res = await fetch('/api/devices/temperature/config');
+ const d = await res.json();
+ this.renderStatus(d);
+ if (d && d.config) this.setForm(d.config);
+ } catch (e) { this.renderStatus(null); }
+ },
+
+ result(msg, ok) {
+ const el = this.el('th-result'); if (!el) return;
+ el.textContent = msg;
+ el.className = 'settings-result ' + (ok ? 'is-ok' : 'is-err');
+ },
+
+ async test() {
+ const btn = this.el('th-test'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Testing…';
+ try {
+ const res = await fetch('/api/devices/temperature/config/test', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(this.readForm()),
+ });
+ if (res.status === 401 || res.status === 403) { this.result('Need control to test.', false); return; }
+ const d = await res.json();
+ if (d.success) {
+ const r = d.result || {};
+ this.result(`OK — ${r.backend || 'connected'}${r.state ? ' · ' + r.state : ''}${r.temperature_c != null ? ' · ' + r.temperature_c + ' °C' : ''}`, true);
+ } else { this.result(`Failed: ${d.error || res.status}`, false); }
+ } catch (e) { this.result(`Error: ${e.message}`, false); }
+ finally { btn.disabled = false; btn.textContent = old; }
+ },
+
+ async apply() {
+ if (!window.confirm('Apply this thermalizer config to the rig?')) return;
+ const btn = this.el('th-apply'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Applying…';
+ try {
+ const res = await fetch('/api/devices/temperature/config', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(this.readForm()),
+ });
+ if (res.status === 401 || res.status === 403) { this.result('Need control to apply.', false); return; }
+ const d = await res.json();
+ // The device-layer 409 (run/ramp active) is flattened to 200 by the
+ // proxy, so detect it via the body flag, not the HTTP status.
+ if (d.blocked) { this.result(`Blocked: ${d.error || 'a run/ramp is active'}`, false); return; }
+ if (d.success && d.applied) { this.result('Applied live.', true); await this.load(); }
+ else if (d.restart_required) { this.result(`Saved — restart the device layer to apply. (${d.error || ''})`, false); }
+ else { this.result(`Failed: ${d.error || (d.detail || res.status)}`, false); }
+ } catch (e) { this.result(`Error: ${e.message}`, false); }
+ finally { btn.disabled = false; btn.textContent = old; }
+ },
+
+ async loadEffective() {
+ const el = this.el('effective-config'); if (!el) return;
+ try {
+ const res = await fetch('/api/config/effective');
+ if (!res.ok) { el.textContent = 'Unavailable.'; return; }
+ const d = await res.json();
+ el.textContent = JSON.stringify(d, null, 2);
+ } catch (e) { el.textContent = 'Unavailable.'; }
+ },
+};
+
+document.addEventListener('DOMContentLoaded', () => ThermalizerSettings.init());
+
+
+/**
+ * AdvancedSettings — restart-required settings.py editors, persisted to
+ * config/settings.local.yml via /api/config/settings-overrides. Renders an
+ * allowlisted set of tunables; saving does NOT apply live (needs a restart).
+ */
+const AdvancedSettings = {
+ el(id) { return document.getElementById(id); },
+
+ async init() {
+ if (!this.el('section-advanced')) return;
+ const save = this.el('adv-save');
+ if (save) save.addEventListener('click', () => this.save());
+ await this.load();
+ },
+
+ renderField(it) {
+ const field = document.createElement('div');
+ field.className = 'settings-field';
+ const tag = it.overridden ? '
(override set)' : '';
+ if (it.type === 'bool') {
+ const lab = document.createElement('label');
+ lab.className = 'settings-checkbox';
+ lab.innerHTML = `
${it.label}${tag}`;
+ lab.querySelector('input').checked = !!it.current;
+ field.appendChild(lab);
+ } else {
+ const lab = document.createElement('label');
+ lab.className = 'settings-label';
+ lab.innerHTML = it.label + tag;
+ const inp = document.createElement('input');
+ inp.className = 'settings-input';
+ inp.dataset.env = it.env;
+ inp.type = it.type === 'str' ? 'text' : 'number';
+ if (it.type === 'float') inp.step = 'any';
+ if (it.current != null) inp.value = it.current;
+ field.appendChild(lab); field.appendChild(inp);
+ }
+ return field;
+ },
+
+ async load() {
+ const wrap = this.el('adv-fields'); if (!wrap) return;
+ try {
+ const res = await fetch('/api/config/settings-overrides');
+ if (!res.ok) { wrap.textContent = 'Unavailable.'; return; }
+ const d = await res.json();
+ const items = d.items || [];
+ wrap.innerHTML = '';
+ // Group items by their `group`, preserving first-seen order.
+ const groups = [];
+ const byName = {};
+ items.forEach(it => {
+ const g = it.group || 'Other';
+ if (!byName[g]) { byName[g] = []; groups.push(g); }
+ byName[g].push(it);
+ });
+ groups.forEach(g => {
+ const head = document.createElement('div');
+ head.className = 'settings-subhead';
+ head.textContent = g;
+ wrap.appendChild(head);
+ byName[g].forEach(it => wrap.appendChild(this.renderField(it)));
+ });
+ } catch (e) { wrap.textContent = 'Unavailable.'; }
+ },
+
+ result(msg, ok) {
+ const el = this.el('adv-result'); if (!el) return;
+ el.textContent = msg;
+ el.className = 'settings-result ' + (ok ? 'is-ok' : 'is-err');
+ },
+
+ async save() {
+ const payload = {};
+ document.querySelectorAll('#adv-fields [data-env]').forEach(inp => {
+ if (inp.type === 'checkbox') payload[inp.dataset.env] = inp.checked;
+ else if (inp.value !== '') payload[inp.dataset.env] = inp.value;
+ });
+ const btn = this.el('adv-save'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Saving…';
+ try {
+ const res = await fetch('/api/config/settings-overrides', {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (res.status === 401 || res.status === 403) { this.result('Need control to save.', false); return; }
+ const d = await res.json();
+ if (res.ok) { this.result(`Saved ${(d.saved || []).length} setting(s) — restart the server to apply.`, true); await this.load(); }
+ else { this.result(`Failed: ${d.detail || res.status}`, false); }
+ } catch (e) { this.result(`Error: ${e.message}`, false); }
+ finally { btn.disabled = false; btn.textContent = old; }
+ },
+};
+
+document.addEventListener('DOMContentLoaded', () => AdvancedSettings.init());
diff --git a/gently/ui/web/static/js/shell.js b/gently/ui/web/static/js/shell.js
new file mode 100644
index 00000000..c85b2474
--- /dev/null
+++ b/gently/ui/web/static/js/shell.js
@@ -0,0 +1,58 @@
+/**
+ * Shell (ux_v2): the grouped left-rail nav (Now / Library / System) + the
+ * session-context strip that replace the flat 8-tab bar.
+ *
+ * CRITICAL: the rail ROUTES THROUGH switchTab(tabId) for every reveal — it
+ * never reimplements tab activation, so each tab's lazy-init side-effect
+ * (HomeApp.init, EmbryosManager.clearDetectionBadge, CampaignsApp.init, …)
+ * still fires. switchTab emits TAB_CHANGED, which keeps the rail's active
+ * state in sync no matter who switched (rail, keyboard shortcut, home card,
+ * hash route). No-ops unless body.ux-v2 is present (flag off → v1 untouched).
+ */
+const Shell = (() => {
+ let railItems = [];
+
+ function setActive(tabName) {
+ railItems.forEach(b => b.classList.toggle('active', b.dataset.tab === tabName));
+ }
+
+ function currentTab() {
+ const active = document.querySelector('.tab.active');
+ return (active && active.dataset.tab) ||
+ (typeof state !== 'undefined' && state.tab) || 'home';
+ }
+
+ function renderStrip(status) {
+ const el = document.getElementById('v2-strip-status');
+ if (!el) return;
+ const s = status || (typeof ConnectionStatus !== 'undefined' ? ConnectionStatus.get() : {});
+ const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0;
+ const conn = s.gentlyConnected ? (s.microscopeConnected ? 'Connected' : 'Online') : 'Offline';
+ el.textContent = `${n} embryo${n === 1 ? '' : 's'} · ${conn}`;
+ }
+
+ function init() {
+ if (!document.body.classList.contains('ux-v2')) return; // flag off → no-op
+
+ railItems = Array.from(document.querySelectorAll('.v2-nav-item'));
+ railItems.forEach(btn => btn.addEventListener('click', () => {
+ if (typeof switchTab === 'function') switchTab(btn.dataset.tab);
+ }));
+ setActive(currentTab());
+
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('TAB_CHANGED', (tabName) => setActive(tabName));
+ ClientEventBus.on('CONNECTION_STATUS', (s) => renderStrip(s));
+ }
+
+ const chatBtn = document.getElementById('v2-rail-chat');
+ if (chatBtn) chatBtn.addEventListener('click', () => {
+ if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) AgentChat.togglePanel(true);
+ });
+
+ renderStrip();
+ }
+
+ document.addEventListener('DOMContentLoaded', init);
+ return {};
+})();
diff --git a/gently/ui/web/static/js/status-store.js b/gently/ui/web/static/js/status-store.js
new file mode 100644
index 00000000..0e50dd30
--- /dev/null
+++ b/gently/ui/web/static/js/status-store.js
@@ -0,0 +1,54 @@
+/**
+ * ConnectionStatus — the single source of truth for connection liveness.
+ *
+ * Fixes the "three disagreeing indicators" bug where the header pill, the home
+ * landing line, and the agent dock each computed connection state from their
+ * own signal at their own time (home.js read state.connected ONCE at tab init,
+ * before the /ws handshake, and never corrected — showing "Offline" while the
+ * header showed "Online").
+ *
+ * Three genuinely distinct signals (kept separate, not flattened):
+ * - gentlyConnected : the main /ws telemetry socket (websocket.js)
+ * - microscopeConnected : the /api/device-status health poll (app.js)
+ * - agentConnected : the /ws/agent chat socket (agent-chat.js)
+ *
+ * Writers call set*(); readers subscribe(). The store is STICKY: subscribe()
+ * immediately replays the current snapshot to the new subscriber, so a late
+ * subscriber can never miss the initial state. Events only fire on real change.
+ */
+const ConnectionStatus = (() => {
+ const s = { gentlyConnected: false, microscopeConnected: false, agentConnected: false };
+
+ function emit() {
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.emit('CONNECTION_STATUS', { ...s });
+ }
+ }
+
+ function set(key, val) {
+ val = !!val;
+ if (s[key] === val) return; // only emit on actual change
+ s[key] = val;
+ emit();
+ }
+
+ return {
+ setGently(v) { set('gentlyConnected', v); },
+ setMicroscope(v) { set('microscopeConnected', v); },
+ setAgent(v) { set('agentConnected', v); },
+ get() { return { ...s }; },
+
+ /**
+ * Subscribe to status changes AND immediately receive the current
+ * snapshot (sticky replay). This is the guard against the original bug:
+ * a subscriber that registers after the first emit still renders from
+ * the correct current state instead of a stale default.
+ */
+ subscribe(handler) {
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('CONNECTION_STATUS', handler);
+ }
+ try { handler({ ...s }); } catch (e) { console.error('ConnectionStatus subscriber error', e); }
+ }
+ };
+})();
diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js
new file mode 100644
index 00000000..86129902
--- /dev/null
+++ b/gently/ui/web/static/js/temperature-graph.js
@@ -0,0 +1,168 @@
+/**
+ * Temperature Graph — hand-rolled SVG line chart for the Devices tab.
+ *
+ * Shows the water-temp trace (solid) and stepped setpoint line (dashed),
+ * backfilled from /api/temperature/{session}/history and then updated live
+ * via TEMPERATURE_UPDATE events from the ClientEventBus.
+ *
+ * No external dependencies. Calm empty state; never renders mock data.
+ *
+ * Usage:
+ * TemperatureGraph.init(containerEl, 'current');
+ * // The component self-subscribes; no extra subscription needed in the caller.
+ * TemperatureGraph.dispose(); // on teardown
+ */
+const TemperatureGraph = (() => {
+ const SVGNS = "http://www.w3.org/2000/svg";
+ const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz
+
+ let _root = null;
+ let _samples = [];
+ let _session = "current";
+
+ function init(container, sessionId) {
+ ClientEventBus.off("TEMPERATURE_UPDATE", onEvent);
+ _root = container;
+ _session = sessionId || "current";
+ _samples = [];
+ backfill();
+ ClientEventBus.on("TEMPERATURE_UPDATE", onEvent);
+ }
+
+ async function backfill() {
+ try {
+ const r = await fetch(`/api/temperature/${_session}/history`);
+ if (!r.ok) { renderEmpty(); return; }
+ const body = await r.json();
+ // Adopt the resolved session_id (e.g. 'current' → real id) so that
+ // subsequent event filtering is consistent.
+ _session = body.session_id || _session;
+ _samples = (body.samples || []).slice(-MAX_POINTS);
+ render();
+ } catch (e) {
+ console.warn('[TemperatureGraph] backfill error:', e);
+ renderEmpty();
+ }
+ }
+
+ function onEvent(data) {
+ // data = {session_id, sample: {t, water_c, setpoint_c, state}}
+ if (!data || !data.sample) return;
+ _samples.push(data.sample);
+ if (_samples.length > MAX_POINTS) _samples.shift();
+ render();
+ }
+
+ function renderEmpty() {
+ if (!_root) return;
+ _root.innerHTML = '
No temperature data yet
';
+ }
+
+ function render() {
+ if (!_root) return;
+ if (!_samples.length) { renderEmpty(); return; }
+
+ const W = _root.clientWidth || 480;
+ const H = 160;
+ const pad = { top: 12, right: 16, bottom: 20, left: 28 };
+
+ const ws = _samples.map(s => s.water_c).filter(v => v != null);
+ const sps = _samples.map(s => s.setpoint_c).filter(v => v != null);
+
+ if (!ws.length) { renderEmpty(); return; }
+
+ const allVals = [...ws, ...sps];
+ const lo = Math.min(...allVals) - 0.5;
+ const hi = Math.max(...allVals) + 0.5;
+ const range = Math.max(0.001, hi - lo);
+ const plotW = W - pad.left - pad.right;
+ const plotH = H - pad.top - pad.bottom;
+ const n = _samples.length;
+
+ const sx = i => pad.left + (i / Math.max(1, n - 1)) * plotW;
+ const sy = v => pad.top + plotH - ((v - lo) / range) * plotH;
+
+ const svg = document.createElementNS(SVGNS, "svg");
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("aria-hidden", "true");
+
+ // Y-axis gridlines (3 levels)
+ const gridG = document.createElementNS(SVGNS, "g");
+ gridG.setAttribute("class", "temp-graph-grid");
+ for (let k = 0; k <= 2; k++) {
+ const v = lo + (k / 2) * (hi - lo);
+ const y = sy(v);
+ const gridLine = document.createElementNS(SVGNS, "line");
+ gridLine.setAttribute("x1", pad.left);
+ gridLine.setAttribute("x2", W - pad.right);
+ gridLine.setAttribute("y1", y);
+ gridLine.setAttribute("y2", y);
+ gridLine.setAttribute("class", "temp-grid-line");
+ gridG.appendChild(gridLine);
+
+ const lbl = document.createElementNS(SVGNS, "text");
+ lbl.setAttribute("x", pad.left - 3);
+ lbl.setAttribute("y", y);
+ lbl.setAttribute("class", "temp-grid-label");
+ lbl.textContent = v.toFixed(1);
+ gridG.appendChild(lbl);
+ }
+ svg.appendChild(gridG);
+
+ // Helper: build a polyline from a point-string array
+ const makeLine = (pts, cls) => {
+ if (!pts.length) return;
+ const p = document.createElementNS(SVGNS, "polyline");
+ p.setAttribute("points", pts.join(" "));
+ p.setAttribute("class", cls);
+ p.setAttribute("fill", "none");
+ svg.appendChild(p);
+ };
+
+ // Water temp — skip null samples (gap rather than crash)
+ const waterPts = [];
+ _samples.forEach((s, i) => {
+ if (s.water_c != null) waterPts.push(`${sx(i).toFixed(1)},${sy(s.water_c).toFixed(1)}`);
+ });
+ makeLine(waterPts, "temp-water");
+
+ // Setpoint — stepped: carry the last known setpoint forward
+ const spPts = [];
+ let lastSP = null;
+ _samples.forEach((s, i) => {
+ const sp = s.setpoint_c != null ? s.setpoint_c : lastSP;
+ if (sp != null) {
+ spPts.push(`${sx(i).toFixed(1)},${sy(sp).toFixed(1)}`);
+ lastSP = sp;
+ }
+ });
+ makeLine(spPts, "temp-setpoint");
+
+ // Current readout line (last sample)
+ const last = _samples[_samples.length - 1];
+ const wStr = last.water_c != null ? `${last.water_c.toFixed(1)} °C` : "—";
+ const spStr = last.setpoint_c != null ? `${last.setpoint_c.toFixed(1)} °C` : "—";
+ const stStr = last.state ? ` · ${last.state}` : "";
+
+ const readout = document.createElement("div");
+ readout.className = "temp-graph-readout";
+ readout.title = "Water temperature → setpoint (state)";
+ readout.textContent = `${wStr} → ${spStr}${stStr}`;
+
+ _root.innerHTML = "";
+ _root.appendChild(readout);
+ _root.appendChild(svg);
+ }
+
+ function dispose() {
+ ClientEventBus.off("TEMPERATURE_UPDATE", onEvent);
+ _root = null;
+ _samples = [];
+ }
+
+ // Exposed for testing / forced refresh from devices.js
+ return { init, dispose, _render: render, _samples: () => _samples };
+})();
+
+window.TemperatureGraph = TemperatureGraph;
diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js
index b0e6d2ac..abf2dee0 100644
--- a/gently/ui/web/static/js/utils.js
+++ b/gently/ui/web/static/js/utils.js
@@ -3,7 +3,67 @@
// ══════════════════════════════════════════════════════════
// Tab and view name constants
-const TABS = { EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' };
+const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook', GALLERY: 'gallery' };
+
+/**
+ * Extract the XY firmware fence (the addressable stage box) from a device-state
+ * properties map. The ASI adapter exposes LowerLimX/UpperLimX/LowerLimY/
+ * UpperLimY in mm; we convert to µm. Single source of truth for both the 2D
+ * devices map and the 3D optical-space view.
+ *
+ * @param {Object} propsByDevice - payload.properties from DEVICE_STATE_UPDATE
+ * @returns {{x:[number,number], y:[number,number]}|null}
+ */
+function extractFirmwareBox(propsByDevice) {
+ if (!propsByDevice) return null;
+ for (const name of Object.keys(propsByDevice)) {
+ const p = propsByDevice[name] || {};
+ const xMinMm = parseFloat(p['LowerLimX(mm)']);
+ const xMaxMm = parseFloat(p['UpperLimX(mm)']);
+ const yMinMm = parseFloat(p['LowerLimY(mm)']);
+ const yMaxMm = parseFloat(p['UpperLimY(mm)']);
+ if (isFinite(xMinMm) && isFinite(xMaxMm) &&
+ isFinite(yMinMm) && isFinite(yMaxMm)) {
+ return {
+ x: [xMinMm * 1000, xMaxMm * 1000], // mm → µm
+ y: [yMinMm * 1000, yMaxMm * 1000],
+ };
+ }
+ }
+ return null;
+}
+
+/**
+ * Build a coordinate mapper from device microns to Three.js scene units.
+ * Centers each axis on its range midpoint and divides by the LARGEST span so
+ * the whole scene fits a ~[-0.5, 0.5] cube while keeping axes proportional
+ * (anisotropic Z vs XY preserved). Returns helpers used by all scene objects
+ * so a single scale governs geometry and camera distance.
+ *
+ * @param {{xRange:[number,number], yRange:[number,number], zRange:[number,number]}} ranges (µm)
+ */
+function makeSceneScaler(ranges) {
+ const xc = (ranges.xRange[0] + ranges.xRange[1]) / 2;
+ const yc = (ranges.yRange[0] + ranges.yRange[1]) / 2;
+ const zc = (ranges.zRange[0] + ranges.zRange[1]) / 2;
+ const xs = Math.abs(ranges.xRange[1] - ranges.xRange[0]);
+ const ys = Math.abs(ranges.yRange[1] - ranges.yRange[0]);
+ const zs = Math.abs(ranges.zRange[1] - ranges.zRange[0]);
+ const maxExtent = Math.max(xs, ys, zs, 1e-6);
+ return {
+ maxExtent,
+ center: { x: xc, y: yc, z: zc },
+ // Map an absolute µm position on one axis into scene space.
+ toScene(um, axis) {
+ const c = axis === 'x' ? xc : axis === 'y' ? yc : zc;
+ return (um - c) / maxExtent;
+ },
+ // Map a µm length (span) into scene units (no centering).
+ scaleLen(um) {
+ return um / maxExtent;
+ },
+ };
+}
/**
* HTML-escape a string (safe for insertion into innerHTML).
diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js
index 38a2794c..cd2ba5d5 100644
--- a/gently/ui/web/static/js/websocket.js
+++ b/gently/ui/web/static/js/websocket.js
@@ -103,7 +103,9 @@ function handleMessage(msg) {
// per event and would lag every other handler), but still emit to the
// client event bus so the Devices tab gets the payload.
if (msg.event_type !== 'DEVICE_STATE_UPDATE' &&
- msg.event_type !== 'BOTTOM_CAMERA_FRAME') {
+ msg.event_type !== 'BOTTOM_CAMERA_FRAME' &&
+ msg.event_type !== 'TEMPERATURE_UPDATE' &&
+ msg.event_type !== 'LIGHTSHEET_FRAME') {
handleFullEvent({
event_type: msg.event_type,
data: msg.data,
@@ -127,6 +129,24 @@ function handleMessage(msg) {
// Switch to embryos tab if not already there
if (state.tab !== 'embryos') switchTab('embryos');
}
+ } 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.
+ if (typeof ProjectionViewer !== 'undefined' && msg.embryo_id != null) {
+ const view = msg.view || '3d_viewer';
+ Promise.resolve(ProjectionViewer.open(msg.embryo_id, msg.timepoint))
+ .then(() => {
+ // Default to the 3D viewer tab when the agent opens it.
+ if (view && typeof ProjectionViewer.selectMethod === 'function') {
+ ProjectionViewer.selectMethod(view);
+ }
+ })
+ .catch((e) => console.warn('open_volume failed', e));
+ }
+ } else if (msg.type === 'session_changed') {
+ // The live agent switched sessions (resume from the Sessions tab) —
+ // reload so every client picks up the new session's state + transcript.
+ window.location.href = '/';
} else if (msg.type === 'ping') {
state.ws.send(JSON.stringify({type: 'pong'}));
} else if (msg.type === 'presence') {
diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py
index 46569758..1fb0d07b 100644
--- a/gently/ui/web/strategy_snapshot.py
+++ b/gently/ui/web/strategy_snapshot.py
@@ -22,7 +22,7 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any
import yaml
@@ -37,10 +37,10 @@
# ui_icon names but resolves them to actual unicode glyphs the swimlane SVG
# can render directly.
_ROLE_ICONS = {
- "star": "★", # ★
- "diamond": "◆", # ◆
- "circle": "●", # ●
- "triangle": "▲", # ▲
+ "star": "★", # ★
+ "diamond": "◆", # ◆
+ "circle": "●", # ●
+ "triangle": "▲", # ▲
}
# Default per-timepoint exposure when nothing on disk tells us otherwise.
@@ -60,9 +60,9 @@
# ---------------------------------------------------------------------------
-def _read_yaml(path: Path) -> Optional[dict]:
+def _read_yaml(path: Path) -> dict | None:
try:
- with open(path, "r", encoding="utf-8") as f:
+ with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except FileNotFoundError:
return None
@@ -80,7 +80,7 @@ def _pick_timelapse_yaml(session_dir: Path, legacy_session_dir: Path) -> dict:
orchestrator that hasn't yet been restarted (still writing to legacy)
isn't shadowed by a stale new-path file.
"""
- candidates: List[Path] = [
+ candidates: list[Path] = [
session_dir / "timelapse.yaml",
legacy_session_dir / "timelapse.yaml",
]
@@ -93,6 +93,7 @@ def _pick_timelapse_yaml(session_dir: Path, legacy_session_dir: Path) -> dict:
return {}
if len(docs) == 1:
return docs[0][1]
+
# Pick by saved_at if present, falling back to file mtime.
def _saved_at_key(item):
path, doc = item
@@ -105,11 +106,12 @@ def _saved_at_key(item):
return path.stat().st_mtime
except OSError:
return 0.0
+
docs.sort(key=_saved_at_key, reverse=True)
return docs[0][1]
-def _parse_iso(s: Optional[str]) -> Optional[datetime]:
+def _parse_iso(s: str | None) -> datetime | None:
if not s:
return None
try:
@@ -118,7 +120,7 @@ def _parse_iso(s: Optional[str]) -> Optional[datetime]:
return None
-def _elapsed_s(t: Optional[datetime], started_at: datetime) -> Optional[float]:
+def _elapsed_s(t: datetime | None, started_at: datetime) -> float | None:
if t is None:
return None
return (t - started_at).total_seconds()
@@ -132,19 +134,21 @@ def _elapsed_s(t: Optional[datetime], started_at: datetime) -> Optional[float]:
@dataclass
class _EmbryoAccum:
"""Mutable accumulator while replaying timeline events for one embryo."""
+
eid: str
- phases: List[dict]
- trigger_events: List[dict]
- power_history_488: List[dict]
+ phases: list[dict]
+ trigger_events: list[dict]
+ power_history_488: list[dict]
- def open_phase(self, mode: str, start_s: float, cadence_s: Optional[float] = None,
- **extra) -> None:
+ def open_phase(
+ self, mode: str, start_s: float, cadence_s: float | None = None, **extra
+ ) -> None:
# If the last phase has no end yet, close it at start_s.
if self.phases:
last = self.phases[-1]
if "end" not in last or last["end"] is None:
last["end"] = start_s
- ph: Dict[str, Any] = {"mode": mode, "start": start_s, "end": None}
+ ph: dict[str, Any] = {"mode": mode, "start": start_s, "end": None}
if cadence_s is not None:
ph["cadence_s"] = cadence_s
ph.update(extra)
@@ -167,7 +171,7 @@ def build_strategy_snapshot(
session_id: str,
*,
horizon_padding_s: float = 1800.0,
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Read the session folder and return the strategy dict the frontend wants.
Parameters
@@ -272,7 +276,9 @@ def build_strategy_snapshot(
"now_offset_s": now_offset_s,
"horizon_s": horizon_s,
"base_interval_s": base_interval_s,
- "dose_budget_base_ms": float(dose_budget_base_ms) if dose_budget_base_ms is not None else None,
+ "dose_budget_base_ms": float(dose_budget_base_ms)
+ if dose_budget_base_ms is not None
+ else None,
"per_timepoint_ms": per_timepoint_ms,
"monitoring_modes": monitoring_modes,
"triggers": triggers,
@@ -285,7 +291,7 @@ def build_strategy_snapshot(
# ---------------------------------------------------------------------------
-def _build_monitoring_modes(mode_names: List[str]) -> List[dict]:
+def _build_monitoring_modes(mode_names: list[str]) -> list[dict]:
"""Resolve each active monitoring mode name into a serialized dict.
The orchestrator persists only the names in ``timelapse.yaml``; we
@@ -300,16 +306,18 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]:
logger.debug("Could not import MONITORING_MODES; skipping mode resolution")
return []
- out: List[dict] = []
+ out: list[dict] = []
for name in mode_names:
factory = MONITORING_MODES.get(name)
if factory is None:
- out.append({
- "name": name,
- "description": "",
- "applies_to_roles": [],
- "params": {},
- })
+ out.append(
+ {
+ "name": name,
+ "description": "",
+ "applies_to_roles": [],
+ "params": {},
+ }
+ )
continue
try:
mode = factory()
@@ -319,15 +327,16 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]:
# Pull declarative knobs (fast_interval, rampdown_*) off the instance.
excluded = {"name", "description", "applies_to_roles"}
params = {
- k: v for k, v in vars(mode).items()
- if not k.startswith("_") and k not in excluded
+ k: v for k, v in vars(mode).items() if not k.startswith("_") and k not in excluded
}
- out.append({
- "name": mode.name,
- "description": mode.description,
- "applies_to_roles": list(mode.applies_to_roles),
- "params": params,
- })
+ out.append(
+ {
+ "name": mode.name,
+ "description": mode.description,
+ "applies_to_roles": list(mode.applies_to_roles),
+ "params": params,
+ }
+ )
return out
@@ -338,31 +347,35 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]:
def _build_triggers(
*,
- interval_rules: List[dict],
- power_rules: List[dict],
- embryo_roles: Dict[str, str],
-) -> List[dict]:
- triggers: List[dict] = []
+ interval_rules: list[dict],
+ power_rules: list[dict],
+ embryo_roles: dict[str, str],
+) -> list[dict]:
+ triggers: list[dict] = []
for r in interval_rules:
- triggers.append({
- "id": r["name"],
- "kind": "interval_rule",
- "label": _humanize_rule_name(r["name"]),
- "when_text": _interval_when_text(r),
- "then_text": _interval_then_text(r),
- "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles),
- "one_time": bool(r.get("one_time", True)),
- })
+ triggers.append(
+ {
+ "id": r["name"],
+ "kind": "interval_rule",
+ "label": _humanize_rule_name(r["name"]),
+ "when_text": _interval_when_text(r),
+ "then_text": _interval_then_text(r),
+ "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles),
+ "one_time": bool(r.get("one_time", True)),
+ }
+ )
for r in power_rules:
- triggers.append({
- "id": r["name"],
- "kind": "power_rule",
- "label": _humanize_rule_name(r["name"]),
- "when_text": _power_when_text(r),
- "then_text": _power_then_text(r),
- "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles),
- "one_time": bool(r.get("one_time", False)),
- })
+ triggers.append(
+ {
+ "id": r["name"],
+ "kind": "power_rule",
+ "label": _humanize_rule_name(r["name"]),
+ "when_text": _power_when_text(r),
+ "then_text": _power_then_text(r),
+ "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles),
+ "one_time": bool(r.get("one_time", False)),
+ }
+ )
return triggers
@@ -406,9 +419,9 @@ def _power_then_text(r: dict) -> str:
def _resolve_applies_to_roles(
- applies_to: Optional[List[str]],
- embryo_roles: Dict[str, str],
-) -> List[str]:
+ applies_to: list[str] | None,
+ embryo_roles: dict[str, str],
+) -> list[str]:
"""``applies_to`` is a list of embryo ids; resolve to a deduplicated
list of role names for the chips. ``None`` means "all roles in the
timelapse".
@@ -430,14 +443,14 @@ def _resolve_applies_to_roles(
# ---------------------------------------------------------------------------
-def _read_embryo_roles(session_dir: Path) -> Dict[str, str]:
+def _read_embryo_roles(session_dir: Path) -> dict[str, str]:
"""Map embryo_id -> role by scanning ``embryos/*/embryo.yaml``.
We read this from the durable per-embryo file rather than timelapse.yaml
so the role is correct even when the embryo isn't in the active
timelapse (yet).
"""
- out: Dict[str, str] = {}
+ out: dict[str, str] = {}
embryos_dir = session_dir / "embryos"
if not embryos_dir.is_dir():
return out
@@ -451,7 +464,7 @@ def _read_embryo_roles(session_dir: Path) -> Dict[str, str]:
return out
-def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]:
+def _stop_condition_from_serialized(d: Any) -> tuple[str, str]:
"""Read the per-embryo stop_condition dict and return ``(spec, kind)``.
``kind`` is ``"bounded"`` when ANY component of the (possibly composite)
@@ -463,10 +476,10 @@ def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]:
if not isinstance(d, dict):
return "manual", "open_ended"
spec = d.get("spec") or "manual"
- types: List[str] = []
+ types: list[str] = []
if d.get("condition_type"):
types.append(d["condition_type"])
- for ad in (d.get("additional") or []):
+ for ad in d.get("additional") or []:
if ad.get("condition_type"):
types.append(ad["condition_type"])
auto_stop = any(t != "manual" for t in types)
@@ -476,13 +489,13 @@ def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]:
def _build_embryos_static(
*,
session_dir: Path,
- tl_embryos: Dict[str, dict],
- embryo_roles: Dict[str, str],
- dose_budget_base_ms: Optional[float],
+ tl_embryos: dict[str, dict],
+ embryo_roles: dict[str, str],
+ dose_budget_base_ms: float | None,
base_interval_s: float,
- started_at: Optional[datetime] = None,
- now_offset_s: Optional[float] = None,
-) -> List[dict]:
+ started_at: datetime | None = None,
+ now_offset_s: float | None = None,
+) -> list[dict]:
"""Build the per-embryo static portion of the snapshot.
Dynamic fields (phases, trigger_events, power_history_488) are seeded
@@ -493,7 +506,7 @@ def _build_embryos_static(
except Exception:
ROLE_REGISTRY = {}
- out: List[dict] = []
+ out: list[dict] = []
# Sort embryo ids so the snapshot is deterministic.
for eid in sorted(tl_embryos.keys()):
ed = tl_embryos[eid] or {}
@@ -503,9 +516,7 @@ def _build_embryos_static(
icon = _ROLE_ICONS.get(role_def.ui_icon if role_def else "circle", "●")
mult = role_def.photodose_budget_multiplier if role_def else 1.0
dose_budget_ms = (
- float(dose_budget_base_ms) * float(mult)
- if dose_budget_base_ms is not None
- else 0.0
+ float(dose_budget_base_ms) * float(mult) if dose_budget_base_ms is not None else 0.0
)
laser_488 = ed.get("laser_power_488_pct")
if laser_488 is None:
@@ -519,49 +530,50 @@ def _build_embryos_static(
stop_spec, stop_kind = _stop_condition_from_serialized(ed.get("stop_condition"))
# Seed: one base-cadence phase from t=0 until now (replay will
# split it as cadence_changed events come in).
- out.append({
- "id": eid,
- "role": role,
- "color": color,
- "icon": icon,
- "dose_used_ms": float(ed.get("total_exposure_ms") or 0.0),
- "dose_budget_ms": dose_budget_ms,
- "tp_acquired": int(ed.get("timepoints_acquired") or 0),
- "stop_condition": stop_spec,
- "stop_kind": stop_kind,
- "laser_488_pct_now": float(laser_488),
- "phases": [
- {
- "mode": "base",
- "start": 0.0,
- "end": None,
- "cadence_s": initial_cadence,
- }
- ],
- "trigger_events": [],
- "power_history_488": [
- {"at": 0.0, "pct": float(laser_488)}
- ],
- # Filled in by _project_forward.
- "projected_cadence_s": initial_cadence,
- "projected_end_s": None,
- # When the embryo was marked complete/terminated, as seconds
- # from session start. Null while still acquiring. The
- # frontend uses this to draw a TERMINATED cap and stop the
- # projection bar; without it, a finished embryo's row would
- # appear to still be acquiring forever.
- "terminated_at_s": _terminated_at_offset(
- ed, started_at, now_offset_s
- ),
- })
+ out.append(
+ {
+ "id": eid,
+ "role": role,
+ "color": color,
+ "icon": icon,
+ "dose_used_ms": float(ed.get("total_exposure_ms") or 0.0),
+ "dose_budget_ms": dose_budget_ms,
+ "tp_acquired": int(ed.get("timepoints_acquired") or 0),
+ "stop_condition": stop_spec,
+ "stop_kind": stop_kind,
+ "laser_488_pct_now": float(laser_488),
+ "phases": [
+ {
+ "mode": "base",
+ "start": 0.0,
+ "end": None,
+ "cadence_s": initial_cadence,
+ }
+ ],
+ "trigger_events": [],
+ "power_history_488": [{"at": 0.0, "pct": float(laser_488)}],
+ # Filled in by _replay_timeline when temperature-tactic events arrive.
+ "temp_protocol": None,
+ "setpoint_changes": [],
+ # Filled in by _project_forward.
+ "projected_cadence_s": initial_cadence,
+ "projected_end_s": None,
+ # When the embryo was marked complete/terminated, as seconds
+ # from session start. Null while still acquiring. The
+ # frontend uses this to draw a TERMINATED cap and stop the
+ # projection bar; without it, a finished embryo's row would
+ # appear to still be acquiring forever.
+ "terminated_at_s": _terminated_at_offset(ed, started_at, now_offset_s),
+ }
+ )
return out
def _terminated_at_offset(
ed: dict,
- started_at: Optional[datetime],
- now_offset_s: Optional[float],
-) -> Optional[float]:
+ started_at: datetime | None,
+ now_offset_s: float | None,
+) -> float | None:
"""Map an embryo's ``completed_at`` ISO timestamp into seconds-from-
session-start. Returns ``None`` if the embryo isn't complete yet or
we don't have the data to compute the offset.
@@ -590,14 +602,14 @@ def _terminated_at_offset(
def _resolve_timeline_paths(
session_dir: Path,
legacy_session_dir: Path,
-) -> List[Tuple[Path, bool]]:
+) -> list[tuple[Path, bool]]:
"""Return the timeline.jsonl paths to read, with a per-source flag.
The flag indicates whether the file is the global legacy timeline
(which mixes multiple sessions and must be filtered by session_id) or
a per-session file (no filtering needed).
"""
- paths: List[Tuple[Path, bool]] = []
+ paths: list[tuple[Path, bool]] = []
# Per-session (new) location.
p = session_dir / "timeline.jsonl"
if p.exists():
@@ -620,8 +632,8 @@ def _replay_timeline(
session_dir: Path,
legacy_session_dir: Path,
session_id: str,
- embryo_dicts: List[dict],
- triggers: List[dict],
+ embryo_dicts: list[dict],
+ triggers: list[dict],
started_at: datetime,
now_offset_s: float,
base_interval_s: float,
@@ -649,20 +661,19 @@ def _replay_timeline(
# We need to know each embryo's current cadence_s as we go (for the
# phase records). Seed from each embryo's initial phase.
- current_cadence: Dict[str, float] = {
- e["id"]: e["phases"][0].get("cadence_s", base_interval_s)
- for e in embryo_dicts
+ current_cadence: dict[str, float] = {
+ e["id"]: e["phases"][0].get("cadence_s", base_interval_s) for e in embryo_dicts
}
# Track last trigger_fired per (embryo, rule) so we can cluster
# consecutive fires into one event with a count.
- last_trigger: Dict[Tuple[str, str], dict] = {}
+ last_trigger: dict[tuple[str, str], dict] = {}
# Collect events from all sources, filtering global file by session_id.
- events: List[Tuple[datetime, dict]] = []
+ events: list[tuple[datetime, dict]] = []
seen_ids: set = set()
for path, is_global in paths:
try:
- with open(path, "r", encoding="utf-8") as f:
+ with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or not line.startswith("{"):
@@ -698,7 +709,7 @@ def _replay_timeline(
# Pre-timelapse event from a previous run in this session — skip.
continue
data = ev.get("data") or {}
- embryo_id = ev.get("embryo_id") or data.get("embryo_id")
+ embryo_id = str(ev.get("embryo_id") or data.get("embryo_id") or "")
if subtype == "cadence_changed" and embryo_id in by_id:
emb = by_id[embryo_id]
@@ -711,12 +722,14 @@ def _replay_timeline(
pass
mode = _phase_mode_from_name(new_phase_name)
_close_open_phase(emb, at_s)
- emb["phases"].append({
- "mode": mode,
- "start": at_s,
- "end": None,
- "cadence_s": current_cadence.get(embryo_id, base_interval_s),
- })
+ emb["phases"].append(
+ {
+ "mode": mode,
+ "start": at_s,
+ "end": None,
+ "cadence_s": current_cadence.get(embryo_id, base_interval_s),
+ }
+ )
elif subtype == "power_changed" and embryo_id in by_id:
wavelength = data.get("wavelength")
@@ -726,10 +739,12 @@ def _replay_timeline(
if new_pct is None:
continue
emb = by_id[embryo_id]
- emb["power_history_488"].append({
- "at": at_s,
- "pct": float(new_pct),
- })
+ emb["power_history_488"].append(
+ {
+ "at": at_s,
+ "pct": float(new_pct),
+ }
+ )
emb["laser_488_pct_now"] = float(new_pct)
elif subtype == "trigger_fired" and embryo_id in by_id:
@@ -739,10 +754,7 @@ def _replay_timeline(
emb = by_id[embryo_id]
key = (embryo_id, rule_name)
prev = last_trigger.get(key)
- if (
- prev is not None
- and at_s - prev["at"] <= _TRIGGER_CLUSTER_GAP_S
- ):
+ if prev is not None and at_s - prev["at"] <= _TRIGGER_CLUSTER_GAP_S:
prev["count"] = prev.get("count", 1) + 1
prev["at"] = at_s # extend cluster to last fire
else:
@@ -755,13 +767,16 @@ def _replay_timeline(
mode = data.get("mode") or "1hz"
hz = 1.0 if mode == "1hz" else 20.0
_close_open_phase(emb, at_s)
- emb["phases"].append({
- "mode": "burst",
- "start": at_s,
- "end": None,
- "frames": int(data.get("frames") or 0),
- "hz": hz,
- })
+ emb["phases"].append(
+ {
+ "mode": "burst",
+ "start": at_s,
+ "end": None,
+ "frames": int(data.get("frames") or 0),
+ "hz": hz,
+ "phase": data.get("phase"),
+ }
+ )
elif subtype == "burst_completed" and embryo_id in by_id:
emb = by_id[embryo_id]
@@ -771,6 +786,28 @@ def _replay_timeline(
# closed at now_offset_s by the tail pass below.
_close_open_phase(emb, at_s)
+ elif subtype == "temp_protocol_started" and embryo_id in by_id:
+ emb = by_id[embryo_id]
+ emb["temp_protocol"] = {
+ "start": at_s,
+ "end": None,
+ "target_setpoint_c": data.get("target_setpoint_c"),
+ "frames": data.get("frames"),
+ "bursts_before": data.get("bursts_before"),
+ "bursts_after": data.get("bursts_after"),
+ }
+
+ elif subtype == "temp_protocol_completed" and embryo_id in by_id:
+ emb = by_id[embryo_id]
+ tp = emb.get("temp_protocol")
+ if tp is not None:
+ tp["end"] = at_s
+
+ elif subtype == "setpoint_changed" and embryo_id in by_id:
+ emb = by_id[embryo_id]
+ to_val = data.get("to")
+ emb["setpoint_changes"].append({"t": at_s, "to": to_val})
+
elif subtype == "stopped":
# Session-level stop: close every open phase at this time.
for emb in embryo_dicts:
@@ -795,7 +832,12 @@ def _ensure_tail_power(emb: dict, now_offset_s: float) -> None:
extends the steady segment to the right edge."""
hist = emb.get("power_history_488") or []
if not hist:
- emb["power_history_488"] = [{"at": now_offset_s, "pct": emb.get("laser_488_pct_now", _DEFAULT_INITIAL_POWER_PCT)}]
+ emb["power_history_488"] = [
+ {
+ "at": now_offset_s,
+ "pct": emb.get("laser_488_pct_now", _DEFAULT_INITIAL_POWER_PCT),
+ }
+ ]
return
if hist[-1]["at"] < now_offset_s:
hist.append({"at": now_offset_s, "pct": hist[-1]["pct"]})
@@ -820,7 +862,7 @@ def _phase_mode_from_name(name: str) -> str:
def _project_forward(
*,
- embryo_dicts: List[dict],
+ embryo_dicts: list[dict],
now_offset_s: float,
per_timepoint_ms: float,
) -> None:
@@ -851,7 +893,7 @@ def _project_forward(
def _compute_horizon(
now_offset_s: float,
- embryo_dicts: List[dict],
+ embryo_dicts: list[dict],
padding_s: float,
) -> float:
"""Pick a horizon that comfortably contains the past + projected future."""
diff --git a/gently/ui/web/templates/_header.html b/gently/ui/web/templates/_header.html
index d8f692e0..cf4a4d03 100644
--- a/gently/ui/web/templates/_header.html
+++ b/gently/ui/web/templates/_header.html
@@ -25,6 +25,8 @@
+ {# Agent lives in the docked right sidebar (collapses to a spark rail) —
+ no header toggle. Ctrl/Cmd+J still opens it. #}
{% endif %}