syntheogenesis / dee /static /catalog.js
github-actions[bot]
Deploy 2565f24
7b284c7
Raw
History Blame Contribute Delete
29.3 kB
/* catalog.js β€” one place that holds everything you have worked on.
===========================================================================
Before this, your work was in three places that did not know about each
other: constructs on Mission Control, a local "recent runs" trail beside
them, and conversations behind a 320px dropdown in the rail. The dropdown
could only OPEN a run β€” it could not rename one and could not delete one β€”
so it silted up with rows all called "Design a more thermostable variant of
this prot…" and there was no way to clear them. A reviewing scientist put it
plainly: no catalog of conversations, and no way to delete constructs or
conversations.
This is that catalog. Both kinds of thing in one panel, searchable, with the
verbs that were missing. And each conversation carries the fold of the
protein it is about, drawn from the run's own saved bench snapshot β€” because
a scientist finds "the one about the beta-lactamase" by recognising it, not
by reading ten near-identical sentences.
Constructs are deletable too, as far as the API allows β€” see CX_DELETE for
which kinds have a route and which are still a server-side gap.
Depends on: TDStructCard (thumbnails), TDCockpit (switchRun/newRun),
TDBench (openConstruct). Degrades if any is missing rather than throwing.
=========================================================================== */
(function () {
"use strict";
var state = {
root: null, open: false, runs: [], constructs: [],
q: "", filter: "all", loading: false, busy: {}, sel: -1, prevFocus: null,
};
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function when(t) {
if (!t) return "";
var ms = typeof t === "number" ? t : Date.parse(t);
if (!isFinite(ms)) return "";
var s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 3600) return Math.max(1, Math.round(s / 60)) + "m ago";
if (s < 86400) return Math.round(s / 3600) + "h ago";
if (s < 7 * 86400) return Math.round(s / 86400) + "d ago";
try { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); }
catch (e) { return ""; }
}
function money(v) {
var n = Number(v || 0);
if (!n) return "";
return "$" + (n < 0.01 ? n.toFixed(4) : n.toFixed(2));
}
/* ── data ───────────────────────────────────────────────────────────── */
function localRuns() {
try {
if (window.TDCockpit && window.TDCockpit.recentRuns) {
return window.TDCockpit.recentRuns() || [];
}
} catch (e) {}
return [];
}
function mergeRuns(server) {
var seen = {}, out = [];
(server || []).forEach(function (r) {
if (!r || !r.run_id || seen[r.run_id]) return;
seen[r.run_id] = 1;
var ws = (r.meta && r.meta.workspace) || {};
out.push({
runId: r.run_id, title: r.title, at: r.updated_at || r.created_at,
steps: r.steps, cost: r.cost_usd, status: r.status,
construct: ws.construct || "", organism: ws.organism || "",
uniprot: ws.uniprot || "", ready: ws.ready || [], server: true,
});
});
// Local rows are the signed-out and not-yet-synced case. They carry no
// bench snapshot, so they get a plate rather than a fold β€” never a
// borrowed one from another run.
localRuns().forEach(function (r) {
if (!r || !r.runId || seen[r.runId]) return;
seen[r.runId] = 1;
out.push({ runId: r.runId, title: r.title, at: r.at, ready: [], server: false });
});
out.forEach(function (r) {
r._t = typeof r.at === "number" ? r.at : Date.parse(r.at || "") || 0;
});
out.sort(function (a, b) { return b._t - a._t; });
return out;
}
function load() {
state.loading = true;
paint();
var runs = fetch("/api/orchestrator/runs")
.then(function (r) { return r.ok ? r.json() : null; })
.catch(function () { return null; });
var mission = fetch("/api/mission")
.then(function (r) { return r.ok ? r.json() : null; })
.catch(function () { return null; });
return Promise.all([runs, mission]).then(function (res) {
state.runs = mergeRuns((res[0] && res[0].runs) || []);
state.constructs = (res[1] && res[1].constructs) || [];
state.loading = false;
paint();
});
}
/* ── shell ──────────────────────────────────────────────────────────── */
function build() {
if (state.root) return state.root;
var root = document.createElement("div");
root.className = "cat";
root.id = "catalog";
root.hidden = true;
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", "Your work");
root.innerHTML =
'<div class="cat-backdrop" data-cat-close></div>' +
'<div class="cat-panel">' +
'<header class="cat-head">' +
'<h2 class="cat-title">Your work</h2>' +
'<button type="button" class="cat-x" data-cat-close aria-label="Close">&times;</button>' +
'</header>' +
'<div class="cat-controls">' +
'<input type="search" class="cat-q" id="catQ" placeholder="Search by name, protein or organism…" ' +
'autocomplete="off" spellcheck="false" aria-label="Search your work">' +
'<div class="cat-seg" role="tablist" aria-label="Filter">' +
'<button type="button" class="cat-segb is-on" data-f="all" role="tab" aria-selected="true">All</button>' +
'<button type="button" class="cat-segb" data-f="runs" role="tab" aria-selected="false">Conversations</button>' +
'<button type="button" class="cat-segb" data-f="constructs" role="tab" aria-selected="false">Constructs</button>' +
'</div>' +
'</div>' +
'<div class="cat-body" id="catBody" tabindex="-1"></div>' +
"</div>";
document.body.appendChild(root);
state.root = root;
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKey);
root.querySelector("#catQ").addEventListener("input", function (e) {
state.q = e.target.value; state.sel = -1; paint();
});
return root;
}
function open() {
build();
state.prevFocus = document.activeElement;
state.open = true;
state.root.hidden = false;
document.body.classList.add("cat-open");
var q = state.root.querySelector("#catQ");
q.value = state.q;
load().then(function () { q.focus(); });
paint();
}
function close() {
if (!state.open) return;
state.open = false;
if (state.root) state.root.hidden = true;
document.body.classList.remove("cat-open");
if (state.prevFocus && state.prevFocus.focus) {
try { state.prevFocus.focus(); } catch (e) {}
}
}
/* ── cards ──────────────────────────────────────────────────────────── */
var READY_LABEL = {
design: "Library", structure: "Structure", crispr: "Guides",
primers: "Primers", plasmid: "Map",
};
/* Where a construct of each kind can be deleted, and what it is called
while you are being asked to confirm it.
The reviewer asked for two things β€” "no catalog of conversations/chats
or way to delete 'constructs' and conversations". Conversations got both
verbs. Constructs got neither, and half of that is a server gap rather
than a UI one: plasmids and primer analyses have owner-checked DELETE
routes, saved LIBRARIES and CRISPR DESIGNS have none, in server.py or in
auth.py. A library is the primary artifact this product makes, so that
is the bigger half of the hole.
This table is the whole switch. A kind with no entry renders no Delete
button, because the alternative is a button that 404s β€” and a delete
control that silently does nothing is worse than an honest absence. Add
`DELETE /api/library/<id>` and `DELETE /api/crispr/designs/<id>` plus
their auth.py counterparts and the two commented lines below finish the
feature; nothing else here changes. */
var CX_DELETE = {
plasmid: { path: "/api/plasmid/library/", noun: "plasmid map" },
primer: { path: "/api/primers/analyses/", noun: "primer analysis" },
// library: { path: "/api/library/", noun: "variant library" },
// crispr: { path: "/api/crispr/designs/", noun: "CRISPR design" },
};
function thumbHtml(r) {
if (r.uniprot) {
return '<span class="cat-thumb" data-sc-host data-sc="idle">' +
'<canvas class="cat-canvas" data-uniprot="' + esc(r.uniprot) + '" ' +
'role="img" aria-label="Predicted structure of ' + esc(r.uniprot) + '"></canvas>' +
'<span class="cat-thumb-msg"></span>' +
'<span class="cat-acc">' + esc(r.uniprot) + "</span></span>";
}
// No accession on this run. Say so β€” a decorative squiggle here would
// read as "here is the protein", which is the one thing it isn't.
return '<span class="cat-thumb" data-sc="none">' +
'<span class="cat-thumb-msg">No structure resolved</span></span>';
}
function runCard(r, i) {
var chips = [];
if (r.construct) chips.push('<span class="cat-chip is-key">' + esc(r.construct) + "</span>");
if (r.organism) chips.push('<span class="cat-chip">' + esc(r.organism) + "</span>");
(r.ready || []).forEach(function (t) {
if (READY_LABEL[t]) chips.push('<span class="cat-chip is-soft">' + esc(READY_LABEL[t]) + "</span>");
});
var meta = [];
if (r.steps) meta.push(r.steps + " step" + (r.steps === 1 ? "" : "s"));
var m = money(r.cost); if (m) meta.push(m);
var w = when(r._t); if (w) meta.push(w);
if (!r.server) meta.push("this browser only");
var current = window.TDCockpit && window.TDCockpit.currentRunId &&
window.TDCockpit.currentRunId() === r.runId;
var busy = state.busy[r.runId];
return '<article class="cat-card' + (current ? " is-current" : "") +
(busy ? " is-busy" : "") + '" data-i="' + i + '" data-run="' + esc(r.runId) + '">' +
thumbHtml(r) +
'<div class="cat-main">' +
'<button type="button" class="cat-open" data-act="open" data-run="' + esc(r.runId) + '">' +
'<span class="cat-name">' + esc(r.title || "Untitled run") + "</span>" +
"</button>" +
(r.status === "awaiting_input"
? '<span class="cat-flag">Waiting on you</span>' : "") +
(chips.length ? '<div class="cat-chips">' + chips.join("") + "</div>" : "") +
'<div class="cat-meta">' + esc(meta.join(" Β· ")) + "</div>" +
(_notes[r.runId] ? '<p class="cat-note" role="status">' + esc(_notes[r.runId]) + "</p>" : "") +
"</div>" +
'<div class="cat-acts">' +
(r.server ? '<button type="button" class="cat-act" data-act="rename" data-run="' + esc(r.runId) +
'" aria-label="Rename this conversation" title="Rename">Rename</button>' : "") +
'<button type="button" class="cat-act is-danger" data-act="delete" data-run="' + esc(r.runId) +
'" aria-label="Delete this conversation" title="Delete">Delete</button>' +
"</div>" +
"</article>";
}
/* One mark per kind of artifact.
Every construct used to get the same glyph: two crossing curves meant as
a DNA helix, drawn without its rungs, at 26px inside a 104x84 bordered
tile. At that size against an empty frame it reads as a large X β€” the
browser's broken-image placeholder, which is exactly what a reviewer
took it for. Worse, it was the same X for a plasmid map, a primer set, a
CRISPR design and a variant library, so the tile cost 104px of card and
carried no information at all.
These are the four things this product makes, and each mark says which:
a ranked list, a circular map, a cut site, a converging pair. Stroke
glyphs on a 24-unit grid, so they stay legible at the tile's size and
inherit the card's ink. */
var CX_GLYPH = {
library: '<path d="M4 6h16M4 12h11M4 18h6"/>',
plasmid: '<circle cx="12" cy="12" r="8"/>' +
'<path d="M12 4a8 8 0 0 1 6.93 4" stroke-width="2.4"/>',
crispr: '<path d="M3 9h6M15 9h6M3 15h6M15 15h6"/>' +
'<path d="M12 3v18" stroke-dasharray="2.5 2.5"/>',
primer: '<path d="M3 12h18"/><path d="M6.5 8.2 10.5 12l-4 3.8"/>' +
'<path d="M17.5 15.8 13.5 12l4-3.8"/>',
};
var CX_GLYPH_FALLBACK = '<path d="M5 5.5c7 3.5 5.5 12.5 12.5 16M5 19.5c7-3.5 5.5-12.5 12.5-16"/>' +
'<path d="M7.6 8.4h8M8.6 15.6h8"/>';
function constructCard(c, i) {
var meta = [];
if (c.kind) meta.push(String(c.kind));
var w = when(c.at); if (w) meta.push(w);
var del = CX_DELETE[c.kind];
var busy = state.busy[c.id];
var glyph = CX_GLYPH[c.kind] || CX_GLYPH_FALLBACK;
return '<article class="cat-card cat-card--cx' + (busy ? " is-busy" : "") +
'" data-i="' + i + '" data-cx="' + esc(c.id) + '" data-kind="' + esc(c.kind || "") + '">' +
'<span class="cat-thumb cat-thumb--cx" data-sc="cx" aria-hidden="true">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" ' +
'stroke-linecap="round" stroke-linejoin="round">' + glyph + "</svg></span>" +
'<div class="cat-main">' +
'<button type="button" class="cat-open" data-act="opencx" data-cx="' + esc(c.id) +
'" data-name="' + esc(c.name) + '">' +
'<span class="cat-name">' + esc(c.name) + "</span></button>" +
'<div class="cat-meta">' + esc(meta.join(" Β· ")) + "</div>" +
(_notes[c.id] ? '<p class="cat-note" role="status">' + esc(_notes[c.id]) + "</p>" : "") +
"</div>" +
(del
? '<div class="cat-acts">' +
'<button type="button" class="cat-act is-danger" data-act="delcx" data-cx="' + esc(c.id) +
'" aria-label="Delete this ' + esc(del.noun) + '" title="Delete">Delete</button>' +
"</div>"
: "") +
"</article>";
}
/* ── render ─────────────────────────────────────────────────────────── */
function matches(hay, q) {
return String(hay || "").toLowerCase().indexOf(q) !== -1;
}
function paint() {
if (!state.root) return;
var body = state.root.querySelector("#catBody");
if (!body) return;
state.root.querySelectorAll(".cat-segb").forEach(function (b) {
var on = b.getAttribute("data-f") === state.filter;
b.classList.toggle("is-on", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
if (state.loading) {
body.innerHTML = '<p class="cat-empty">Loading your work…</p>';
return;
}
var q = state.q.trim().toLowerCase();
var runs = state.filter === "constructs" ? [] : state.runs.filter(function (r) {
return !q || matches(r.title, q) || matches(r.construct, q) ||
matches(r.organism, q) || matches(r.uniprot, q);
});
var cxs = state.filter === "runs" ? [] : state.constructs.filter(function (c) {
return !q || matches(c.name, q) || matches(c.kind, q);
});
if (!runs.length && !cxs.length) {
body.innerHTML = '<p class="cat-empty">' +
(state.q ? "Nothing matches that."
: "Nothing here yet. Start a conversation and it will show up.") +
"</p>";
return;
}
var html = "", i = 0;
if (runs.length) {
html += '<h3 class="cat-sec">Conversations <span>' + runs.length + "</span></h3>";
html += '<div class="cat-grid">' + runs.map(function (r) { return runCard(r, i++); }).join("") + "</div>";
}
if (cxs.length) {
html += '<h3 class="cat-sec">Constructs <span>' + cxs.length + "</span></h3>";
html += '<div class="cat-grid">' + cxs.map(function (c) { return constructCard(c, i++); }).join("") + "</div>";
}
body.innerHTML = html;
if (window.TDStructCard) window.TDStructCard.observe(body);
}
/* ── actions ────────────────────────────────────────────────────────── */
function onClick(e) {
if (e.target.closest("[data-cat-close]")) { close(); return; }
var btn = e.target.closest("[data-act]");
if (btn) {
var act = btn.getAttribute("data-act");
if (act === "open") { openRun(btn.getAttribute("data-run")); return; }
if (act === "opencx") { openConstruct(btn); return; }
if (act === "rename") { startRename(btn.getAttribute("data-run")); return; }
if (act === "delete") { confirmDeleteRun(btn.getAttribute("data-run")); return; }
if (act === "delcx") { confirmDeleteConstruct(btn.getAttribute("data-cx")); return; }
return;
}
var seg = e.target.closest(".cat-segb");
if (seg) { state.filter = seg.getAttribute("data-f"); state.sel = -1; paint(); }
}
function openRun(runId) {
if (!runId) return;
close();
if (window.TDCockpit && window.TDCockpit.switchRun) window.TDCockpit.switchRun(runId);
}
function openConstruct(btn) {
close();
if (window.TDBench && window.TDBench.openConstruct) {
window.TDBench.openConstruct({ name: btn.getAttribute("data-name") || "Construct",
id: btn.getAttribute("data-cx") });
}
}
function cardBy(attr, id) {
if (!state.root || !id) return null;
// Ids are minted as uuid4().hex, so an attribute-value scan is exact
// without needing CSS.escape (which older Safari lacks).
var all = state.root.querySelectorAll(".cat-card");
for (var i = 0; i < all.length; i++) {
if (all[i].getAttribute(attr) === id) return all[i];
}
return null;
}
function cardFor(runId) { return cardBy("data-run", runId); }
/* Rename in place. A prompt() would be two fewer lines and would also be a
modal inside a modal that cannot be styled, cannot be cancelled with
Escape reliably, and is blocked outright in some embedded browsers. */
function startRename(runId) {
var card = cardFor(runId);
var run = state.runs.filter(function (r) { return r.runId === runId; })[0];
if (!card || !run) return;
var main = card.querySelector(".cat-main");
var opener = card.querySelector(".cat-open");
if (!main || card.querySelector(".cat-rename")) return;
var form = document.createElement("form");
form.className = "cat-rename";
form.innerHTML = '<input type="text" class="cat-rename-i" aria-label="New name" maxlength="120">' +
'<button type="submit" class="cat-act">Save</button>' +
'<button type="button" class="cat-act" data-cancel>Cancel</button>';
// Directly under the name it replaces, wherever that sits.
if (opener && opener.nextSibling) main.insertBefore(form, opener.nextSibling);
else main.appendChild(form);
var input = form.querySelector("input");
input.value = run.title || "";
input.focus();
input.select();
form.querySelector("[data-cancel]").addEventListener("click", function () { form.remove(); });
input.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.stopPropagation(); form.remove(); }
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var title = input.value.trim();
if (!title) { input.focus(); return; }
form.remove();
doRename(runId, title);
});
}
function doRename(runId, title) {
state.busy[runId] = 1; paint();
fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title }),
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
delete state.busy[runId];
if (d && d.ok) {
state.runs.forEach(function (r) { if (r.runId === runId) r.title = d.title || title; });
} else {
note(runId, (d && d.error) || "Couldn't rename that.");
}
paint();
}).catch(function () {
delete state.busy[runId];
note(runId, "Couldn't reach the server.");
paint();
});
}
/* Two-step delete, inline. There is no undo behind any of these, so the
confirm names the thing that is about to go rather than asking "are you
sure?" about an unnamed row. */
function confirmDelete(card, message, onYes) {
if (!card || card.querySelector(".cat-confirm")) return;
var bar = document.createElement("div");
bar.className = "cat-confirm";
bar.innerHTML = "<span>" + message + "</span>" +
'<button type="button" class="cat-act is-danger" data-yes>Delete</button>' +
'<button type="button" class="cat-act" data-no>Keep</button>';
card.appendChild(bar);
bar.querySelector("[data-no]").addEventListener("click", function () { bar.remove(); });
bar.querySelector("[data-yes]").addEventListener("click", function () {
bar.remove(); onYes();
});
bar.querySelector("[data-yes]").focus();
}
function confirmDeleteRun(runId) {
var run = state.runs.filter(function (r) { return r.runId === runId; })[0];
if (!run) return;
confirmDelete(cardFor(runId),
"Delete β€œ" + esc(run.title || "Untitled run") +
"”? Its transcript goes with it.",
function () { doDelete(runId); });
}
/* Constructs are saved WORK β€” a plasmid map, a primer analysis β€” not a
conversation about it, so the confirm says which kind is going and does
not promise anything about the run that produced it. */
function confirmDeleteConstruct(cxId) {
var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0];
if (!cx) return;
var del = CX_DELETE[cx.kind];
if (!del) return;
confirmDelete(cardBy("data-cx", cxId),
"Delete the " + esc(del.noun) + " β€œ" + esc(cx.name || "Untitled") +
"”? This removes the saved record.",
function () { doDeleteConstruct(cxId); });
}
function doDeleteConstruct(cxId) {
var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0];
var del = cx && CX_DELETE[cx.kind];
if (!del) return;
state.busy[cxId] = 1; paint();
fetch(del.path + encodeURIComponent(cxId), { method: "DELETE" })
.then(function (r) {
return r.json().catch(function () { return { ok: r.ok }; });
})
.then(function (d) {
delete state.busy[cxId];
if (d && d.ok) {
state.constructs = state.constructs.filter(function (c) {
return String(c.id) !== String(cxId);
});
// Mission Control lists the same constructs from the same
// endpoint. Leaving it showing a card whose record is gone
// is the silted-up-dropdown problem in a second place.
try {
if (window.TDMission && window.TDMission.reload) window.TDMission.reload();
} catch (e) {}
} else {
note(cxId, (d && d.error) || "Couldn't delete that.");
}
paint();
}).catch(function () {
delete state.busy[cxId];
note(cxId, "Couldn't reach the server.");
paint();
});
}
function doDelete(runId) {
state.busy[runId] = 1; paint();
fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), { method: "DELETE" })
.then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
delete state.busy[runId];
if (d && d.ok) {
var wasCurrent = window.TDCockpit && window.TDCockpit.currentRunId &&
window.TDCockpit.currentRunId() === runId;
state.runs = state.runs.filter(function (r) { return r.runId !== runId; });
// Deleting the conversation you are looking at has to move
// you somewhere: leaving the rail showing a run that no
// longer exists is worse than the dropdown ever was.
if (wasCurrent && window.TDCockpit && window.TDCockpit.newRun) {
window.TDCockpit.newRun();
}
} else {
note(runId, (d && d.error) || "Couldn't delete that.");
}
paint();
}).catch(function () {
delete state.busy[runId];
note(runId, "Couldn't reach the server.");
paint();
});
}
var _notes = {};
function note(runId, msg) {
_notes[runId] = msg;
setTimeout(function () { delete _notes[runId]; paint(); }, 6000);
}
/* ── keyboard ───────────────────────────────────────────────────────── */
function cards() {
return state.root ? Array.prototype.slice.call(state.root.querySelectorAll(".cat-card")) : [];
}
function onKey(e) {
if (e.key === "Escape") { e.preventDefault(); close(); return; }
var list = cards();
if (!list.length) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
state.sel += (e.key === "ArrowDown" ? 1 : -1);
if (state.sel < 0) state.sel = list.length - 1;
if (state.sel >= list.length) state.sel = 0;
list.forEach(function (c, i) { c.classList.toggle("is-sel", i === state.sel); });
var pick = list[state.sel];
if (pick) {
pick.scrollIntoView({ block: "nearest" });
var opener = pick.querySelector(".cat-open");
if (opener) opener.focus();
}
return;
}
if (e.key === "Enter" && state.sel >= 0) {
var card = list[state.sel];
if (!card) return;
e.preventDefault();
if (card.getAttribute("data-run")) openRun(card.getAttribute("data-run"));
else openConstruct(card.querySelector("[data-act='opencx']"));
}
}
document.addEventListener("keydown", function (e) {
if (!state.open) return;
if (e.key === "Escape") { e.preventDefault(); close(); }
});
window.TDCatalog = { open: open, close: close, reload: load,
isOpen: function () { return state.open; } };
})();