/* 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 =
'
' +
'
' +
'' +
'
Your work
' +
'' +
'' +
'
' +
'' +
'
' +
'' +
'' +
'' +
'
' +
'
' +
'' +
"
";
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/` and `DELETE /api/crispr/designs/` 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 '' +
'' +
'' +
'' + esc(r.uniprot) + "";
}
// 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 '' +
'No structure resolved';
}
function runCard(r, i) {
var chips = [];
if (r.construct) chips.push('' + esc(r.construct) + "");
if (r.organism) chips.push('' + esc(r.organism) + "");
(r.ready || []).forEach(function (t) {
if (READY_LABEL[t]) chips.push('' + esc(READY_LABEL[t]) + "");
});
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 '' +
thumbHtml(r) +
'
" +
"";
}
/* 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: '',
plasmid: '' +
'',
crispr: '' +
'',
primer: '' +
'',
};
var CX_GLYPH_FALLBACK = '' +
'';
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 '' +
'' +
'" +
'
' +
'" +
'
' + esc(meta.join(" · ")) + "
" +
(_notes[c.id] ? '
' + esc(_notes[c.id]) + "
" : "") +
"
" +
(del
? '
' +
'' +
"
"
: "") +
"";
}
/* ── 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 = '
";
}
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 = '' +
'' +
'';
// 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 = "" + message + "" +
'' +
'';
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; } };
})();