syntheogenesis / dee /static /assistant.js
Tengo Gzirishvili
Re-arch Phase 1: Mission Control home behind the td-ui flag
0022959
Raw
History Blame Contribute Delete
63.5 kB
/* TuringDNA Β· Assistant β€” conversational orchestrator client.
*
* External file (not inline) because assistant.html is served statically and
* the site CSP (script-src 'self' 'nonce-…') refuses un-nonced inline scripts;
* a same-origin /static/ file is permitted by 'self'.
*
* Talks to the agent server: POST /api/agent/session to open a session, then
* POST /api/agent/step { session_id, message } β†’ StepResult { assistant_message,
* tool_result, tool_name, phase, error }. Conversations persist in localStorage
* so the history dropdown (anchored under the conversation title) survives
* reloads.
* When the agent backend isn't reachable (e.g. OPENROUTER_API_KEY unset on
* this deploy), sends degrade to a quiet inline notice β€” the interface stays
* fully navigable. */
(function () {
"use strict";
// Preview mode: set true to disable the composer and show only the seeded
// demo sessions, with zero backend calls β€” useful for a design/QA pass
// without spending on OpenRouter. Live as of 2026-07-10.
var PREVIEW_MODE = false;
// The four suggested openers on an empty conversation, one per phase.
var SUGGESTIONS = [
{ k: "Β§01 Β· Design", t: "Design a saturation-mutagenesis library for my protein's active site." },
{ k: "Β§02 Β· Build", t: "Map and annotate this plasmid, then design cloning primers." },
{ k: "Β§03 Β· Edit", t: "Find high-specificity CRISPR guides to knock out a gene." },
{ k: "Β§04 Β· Learn", t: "Here are my bench results β€” plan a smarter Round 2." },
];
// While a request is in flight there's no real progress feed from the
// server (one blocking POST, no streaming) β€” so this doesn't claim
// false precision about what's happening server-side. It's a cheap,
// honest keyword guess at which tool the message will route to, used
// only to make the wait feel like part of the Design/Build/Edit/Learn
// workflow instead of a content-free spinner (requested 2026-07-11:
// "create a preview for the workflow so it's more interactive when the
// actual engineering takes place").
var THINKING_STEPS = {
crispr: ["Scanning the target sequence…", "Scoring candidate guides…", "Checking off-target sites…"],
primers: ["Reading the insert boundaries…", "Searching primer candidates…", "Checking melting temperatures…"],
variant: ["Scoring substitutions with ESM-2…", "Ranking candidate variants…", "Assembling the library…"],
general: ["Reading the request…", "Routing across Design β†’ Build β†’ Edit β†’ Learn…", "Running the right tool…"],
};
function guessTrack(text) {
text = String(text || "").toLowerCase();
if (/crispr|guide ?rna|sgrna|knockout|cas9|cas12|off-target/.test(text)) return "crispr";
if (/primer|pcr|clon|amplicon|\binsert\b|gibson|golden gate/.test(text)) return "primers";
if (/variant|mutation|evolve|library|esm|fitness|saturation|round ?2/.test(text)) return "variant";
return "general";
}
// Exact titles of the seedExamples() below β€” used to retroactively
// identify seed chats that predate the seed:true tag (persisted by an
// earlier version of this file, before that tag existed). Matched only
// when sessionId is still null (see load()): a thread the user
// actually typed into, even one that started from a seed template,
// already has a real sessionId and must never be silently dropped.
var SEED_TITLES = [
"Saturation library β€” TEM-1 Ξ²-lactamase",
"CRISPR knockouts β€” E. coli polA",
"Cloning primers β€” pET-28a insert",
"Round 2 from activity assay",
];
var STORE = "td-assistant-chats";
var els = {
list: document.getElementById("chatList"),
thread: document.getElementById("thread"),
inner: document.getElementById("threadInner"),
input: document.getElementById("input"),
send: document.getElementById("send"),
newBtn: document.getElementById("newChat"),
topTitle: document.getElementById("topTitle"),
topSub: document.getElementById("topSub"),
phaseRail: document.getElementById("phaseRail"),
hint: document.querySelector(".asst-hint"),
topLead: document.getElementById("topLead"),
titleBtn: document.getElementById("titleBtn"),
historyMenu: document.getElementById("historyMenu"),
previewBar: document.getElementById("previewBar"),
contextMeter: document.getElementById("contextMeter"),
contextFill: document.getElementById("contextFill"),
contextPct: document.getElementById("contextPct"),
charCount: document.getElementById("charCount"),
liveRegion: document.getElementById("liveRegion"),
};
// Mirrors _AGENT_MESSAGE_MAX_CHARS in dee/server.py β€” keep in sync.
// The server rejects (400, kind=message_too_long) over this rather
// than silently truncating (a real incident: a pasted sequence got
// cut mid-strand with zero indication). This is the pre-emptive,
// client-side half of that fix.
var MESSAGE_MAX_CHARS = 4000;
var DEFAULT_HINT = els.hint ? els.hint.innerHTML : "";
var state = { chats: [], activeId: null, busy: false, lockUntil: 0 };
// ── Persistence ---------------------------------------------------------
function load() {
try { state.chats = JSON.parse(localStorage.getItem(STORE)) || []; }
catch (e) { state.chats = []; }
if (PREVIEW_MODE) {
if (!state.chats.length) { state.chats = seedExamples(); persist(); }
return;
}
// Live: strip seed demo chats already sitting in this browser's
// localStorage β€” they read as real history now, which is
// misleading. Two generations: chats tagged seed:true (current
// seedExamples()), and older ones persisted before that tag
// existed, matched by title + still-null sessionId (see
// SEED_TITLES comment β€” never drops a chat the user has actually
// used, even one that started from a seed template).
var before = state.chats.length;
state.chats = state.chats.filter(function (c) {
if (c.seed) return false;
if (!c.sessionId && SEED_TITLES.indexOf(c.title) !== -1) return false;
return true;
});
if (state.chats.length !== before) persist();
}
function persist() { try { localStorage.setItem(STORE, JSON.stringify(state.chats)); } catch (e) {} }
// Seed a realistic set of conversations so the surface reads as a working
// instrument, not an empty shell, in PREVIEW_MODE only. Never shown live.
function seedExamples() {
var now = Date.now(), hr = 3600e3, dy = 24 * hr;
return [
{ id: uid(), sessionId: null, seed: true, phase: "Design", updatedAt: now - 40 * 60e3,
title: "Saturation library β€” TEM-1 Ξ²-lactamase", messages: [
{ role: "user", text: "Design a saturation-mutagenesis library across the TEM-1 Ξ²-lactamase active site (residues 68–72, 235–240)." },
{ role: "assistant", text: "I scored every substitution at those positions with ESM-2 and ranked a focused library. Prioritising variants with a favourable predicted Ξ”LL keeps the plate size down while covering the catalytic pocket." },
{ role: "tool", tool: { name: "design_library", status: "done", rows: [["variants ranked", "380"], ["positions", "10"], ["top Ξ”LL", "+4.7"], ["est. plate", "1 Γ— 384"]] } },
{ role: "assistant", text: "Top candidates are ready to open in the Plasmid Editor, or I can move straight to primers for the winners. Want to proceed to Build?" },
] },
{ id: uid(), sessionId: null, seed: true, phase: "Edit", updatedAt: now - 5 * hr,
title: "CRISPR knockouts β€” E. coli polA", messages: [
{ role: "user", text: "Find high-specificity guides to knock out polA in E. coli K-12." },
{ role: "assistant", text: "I designed guides against polA and screened each for genome-wide off-targets by CFD. Three clear the specificity bar with cut sites early in the ORF." },
{ role: "tool", tool: { name: "design_crispr", status: "done", rows: [["guides", "24"], ["pass specificity", "3"], ["best on-target", "0.82"], ["min off-target", "0.02"]] } },
] },
{ id: uid(), sessionId: null, seed: true, phase: "Build", updatedAt: now - 1 * dy - 2 * hr,
title: "Cloning primers β€” pET-28a insert", messages: [
{ role: "user", text: "Design Gibson primers to drop my insert into pET-28a at NdeI/XhoI." },
{ role: "assistant", text: "Here are balanced Gibson primers with matched Tm and clean 3β€² ends, plus the overlap arms for the pET-28a backbone." },
{ role: "tool", tool: { name: "design_primers", status: "done", rows: [["primer pairs", "2"], ["mean Tm", "61.4 Β°C"], ["overlap", "22 nt"]] } },
] },
{ id: uid(), sessionId: null, seed: true, phase: "Learn", updatedAt: now - 3 * dy,
title: "Round 2 from activity assay", messages: [
{ role: "user", text: "I logged activity for 44 variants β€” propose Round 2." },
{ role: "assistant", text: "I fit the surrogate to your 44 outcomes and proposed a Round-2 batch that trades off exploitation of the current best against exploring under-sampled positions." },
{ role: "tool", tool: { name: "propose_round2", status: "done", rows: [["fit RΒ²", "0.71"], ["proposed", "48"], ["new positions", "6"]] } },
] },
];
}
// ── Rendering: history dropdown -------------------------------------
function relTime(ts) {
var d = Date.now() - ts, m = 60e3, h = 60 * m, day = 24 * h;
if (d < m) return "just now";
if (d < h) return Math.floor(d / m) + "m ago";
if (d < day) return Math.floor(d / h) + "h ago";
if (d < 7 * day) return Math.floor(d / day) + "d ago";
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function groupOf(ts) {
var d = Date.now() - ts, day = 24 * 3600e3;
if (d < day) return "Today";
if (d < 2 * day) return "Yesterday";
if (d < 7 * day) return "Previous 7 days";
return "Earlier";
}
function renderList() {
var sorted = state.chats.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; });
var html = "", lastGroup = null;
sorted.forEach(function (c) {
var g = groupOf(c.updatedAt);
if (g !== lastGroup) { html += '<div class="asst-group-label">' + g + "</div>"; lastGroup = g; }
html += '<div class="asst-chat' + (c.id === state.activeId ? " active" : "") + '" data-id="' + c.id + '" role="button" tabindex="0">' +
'<div class="asst-chat-title">' + esc(c.title) + "</div>" +
'<div class="asst-chat-meta">' + esc(phaseTag(c.phase)) + " Β· " + relTime(c.updatedAt) + "</div>" +
'<button class="asst-chat-del" data-del="' + c.id + '" aria-label="Delete conversation" title="Delete">' +
'<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M9 7V5h6v2M7 7l1 12h8l1-12"/></svg>' +
"</button>" +
"</div>";
});
els.list.innerHTML = html || '<div class="asst-group-label">No conversations yet</div>';
}
function phaseTag(p) { return "Β§0" + ({ Design: 1, Build: 2, Edit: 3, Learn: 4 }[p] || 1) + " " + (p || "Design"); }
// ── Rendering: thread ---------------------------------------------------
function active() { return state.chats.find(function (c) { return c.id === state.activeId; }) || null; }
function renderThread() {
var c = active();
setPhase(c ? c.phase : "Design");
els.topTitle.textContent = c ? c.title : "New conversation";
els.topSub.textContent = c && c.sessionId ? "session " + c.sessionId.slice(0, 8) + " Β· the engine runs your tools"
: "Turing Β· the engine runs your tools";
renderContextMeter(c);
if (!c || !c.messages.length) { els.inner.innerHTML = emptyState(); return; }
var html = "";
c.messages.forEach(function (m) {
if (m.role === "user") {
html += '<div class="asst-turn user"><div class="asst-bubble">' + formatMessageText(m.text) + "</div></div>";
} else if (m.role === "assistant") {
html += '<div class="asst-turn assistant"><div class="asst-role"><span class="dot"></span>TuringDNA</div>' +
'<div class="asst-body">' + formatMessageText(m.text) + "</div></div>";
} else if (m.role === "tool") {
html += toolCard(m.tool);
} else if (m.role === "limit") {
html += '<div class="asst-turn"><div class="asst-limit">' +
'<div class="asst-limit-t">' + esc(m.text) + "</div>" +
'<div class="asst-limit-s">Upgrade to Pro for a higher daily limit' + (m.resets_in_s ? " Β· resets in " + Math.max(1, Math.ceil(m.resets_in_s / 3600)) + " h" : "") + ".</div>" +
'<a class="asst-limit-cta" href="https://turingdna.com/signup/?from=assistant">Upgrade to Pro</a>' +
"</div></div>";
} else if (m.role === "signin") {
// Anonymous trial window expired β€” distinct from "limit"
// above (no Pro upsell, just "make a free account").
html += '<div class="asst-turn"><div class="asst-limit">' +
'<div class="asst-limit-t">' + esc(m.text) + "</div>" +
'<a class="asst-limit-cta" href="' + esc(m.signin_url) + '">Sign in</a>' +
"</div></div>";
} else if (m.role === "system") {
html += '<div class="asst-turn"><div class="asst-sys">' + esc(m.text) + "</div></div>";
}
});
els.inner.innerHTML = html;
// Re-append the thinking indicator if THIS chat is the one a
// request is actually in flight for β€” see the long comment on
// _thinkTrack below for why this render (not just the one
// showThinking() itself triggers) needs to know about it.
if (state.busy && _thinkTrack && c.id === _thinkChatId) appendThinkingNode();
scrollBottom();
}
function toolCard(t) {
if (t.status === "signin_required") {
return '<div class="asst-turn assistant"><div class="asst-tool">' +
'<div class="asst-tool-head"><span class="glyph">⌁</span><span class="asst-tool-name">' + esc(t.name) + "</span>" +
'<span class="asst-tool-status signin"><span class="tick"></span>sign in required</span></div>' +
'<div class="asst-tool-body"><div class="asst-limit asst-limit-inline">' +
'<div class="asst-limit-t">' + esc(t.signin_text || "This requires a free account.") + "</div>" +
'<a class="asst-limit-cta" href="' + esc(t.signin_url) + '">Sign in</a>' +
"</div></div>" +
"</div></div>";
}
var rows = (t.rows || []).map(function (r) {
return '<div class="kv"><span>' + esc(r[0]) + '</span><b>' + esc(r[1]) + "</b></div>";
}).join("");
var items = (t.items || []).map(function (it) {
return '<div class="asst-tool-item"><span>' + esc(it[0]) + '</span><span>' + esc(it[1]) + "</span></div>";
}).join("");
// Real, orderable sequences (e.g. DE variant DNA) β€” each gets its
// own labeled seqBlock (collapse/expand + Copy + Download), not
// just a text summary row. Kept separate from `items` rather than
// overloading that plain 2-column format with something that needs
// its own interactive controls.
var seqItems = (t.seqItems || []).map(function (si) {
return '<div class="asst-tool-seq"><div class="asst-tool-seq-label">' + esc(si.label) + "</div>" +
seqBlock(si.seq, { label: si.label, highlightRanges: si.highlightRanges }) +
"</div>";
}).join("");
var failed = t.status === "failed";
var body = rows || items || seqItems
? (rows + (items ? '<div class="asst-tool-items">' + items + "</div>" : "") + seqItems)
: esc(JSON.stringify(t.result || {}));
return '<div class="asst-turn assistant"><div class="asst-tool">' +
'<div class="asst-tool-head"><span class="glyph">⌁</span><span class="asst-tool-name">' + esc(t.name) + "</span>" +
'<span class="asst-tool-status' + (failed ? " failed" : "") + '"><span class="tick"></span>' + esc(t.status || "done") + "</span></div>" +
'<div class="asst-tool-body">' + body + "</div>" +
"</div></div>";
}
// Turns a raw tool_result (dee/core/agent_tools.py's JSON return value)
// into the {name, status, rows, items} shape toolCard() renders β€” a
// short stats summary plus a preview of what the tool actually
// produced, instead of a JSON.stringify dump. Unknown tool names or
// unrecognized result shapes fall back to that dump (toolCard's own
// fallback), so a new tool added later without a matching case here
// still renders something, just not richly. Raw snake_case names
// (design_crispr_guides, etc.) are used as-is for the card title β€”
// matches the seed demo conversations' own naming (design_library,
// design_crispr, ...) and the monospace "function call" styling.
function shapeToolResult(name, result) {
result = result || {};
var label = name || "tool";
if (result.kind === "signin_required") {
// A gated tool call from an anonymous session (agent_tools.py's
// execute_tool, requires_signin check) β€” distinct from a real
// failure. Falling through to the generic ok===false case below
// used to render this as a plain red error row with no way to
// act on it; give it the same clickable-CTA treatment as the
// route-level trial-expiry signin card (role: "signin" above)
// instead (found 2026-07-12, more visible now Turing is the
// default entry point and anonymous visitors hit it more).
return { name: label, status: "signin_required", result: result,
signin_url: "https://turingdna.com/signin/?from=assistant",
signin_text: result.error || "This requires a free account." };
}
if (result.ok === false) {
return { name: label, status: "failed", result: result,
rows: [["error", result.error || "unknown error"]] };
}
if (name === "recommend_promoter") {
var proms = result.promoters || [];
return { name: label, status: "done", result: result,
rows: [["host", String(result.host || "?")],
["promoters found", String(proms.length)]],
items: proms.map(function (p) {
return [p.name || "?", p.tier || "?"];
}) };
}
if (name === "fetch_sequence") {
// No `items` here on purpose β€” the resolved sequence itself
// (which can be thousands of nt) goes back to the model to
// feed the next tool call, not re-rendered raw a second time
// in this card. Keeps the card a scannable summary instead of
// risking the same "wall of text" the sequence-collapse fix
// (2026-07-11) exists to prevent.
return { name: label, status: "done", result: result,
rows: [["resolved", String(result.label || result.kind || "?")],
["length", result.length != null ? result.length.toLocaleString() + " bp" : "?"],
["source", String(result.source || "?")]] };
}
if (name === "design_crispr_guides") {
var guides = result.guides || [];
var crisprRows = [["guides found", String(result.n_guides != null ? result.n_guides : guides.length)],
["enzyme", String(result.enzyme || "cas9")]];
// Only shown once a rebuild has actually populated the cross-user
// aggregate for this tool β€” 0/absent means no blend happened,
// not an error (see load_cached_tool_prior in outcomes.py).
if (result.field_prior_keys) crisprRows.push(["field priors applied", result.field_prior_keys + " substitution types"]);
return { name: label, status: "done", result: result,
rows: crisprRows,
items: guides.slice(0, 5).map(function (g) {
return [g.spacer || "?", "score " + (g.composite_score != null ? g.composite_score.toFixed(2) : "?")];
}) };
}
if (name === "design_primers") {
var pairs = result.pairs || [];
return { name: label, status: "done", result: result,
rows: [["pairs found", String(pairs.length)],
["forward candidates", String(result.n_forward != null ? result.n_forward : "?")],
["reverse candidates", String(result.n_reverse != null ? result.n_reverse : "?")]],
items: pairs.slice(0, 5).map(function (p) {
return [(p.forward_seq || "?") + " / " + (p.reverse_seq || "?"), (p.product_size != null ? p.product_size + " bp" : "?")];
}) };
}
if (name === "design_variant_library") {
var variants = result.variants || [];
var deRows = [["positions scored", String(result.n_scored_positions != null ? result.n_scored_positions : "?")],
["variants proposed", String(variants.length)],
["model", String(result.model || "small")]];
// Same caveat as the CRISPR card above β€” 0/absent just means no
// rebuild has populated public.mutation_priors yet, not a failure.
if (result.field_prior_substitutions) deRows.push(["field priors applied", result.field_prior_substitutions + " substitution types"]);
return { name: label, status: "done", result: result,
rows: deRows,
// Each variant's actual DNA (added 2026-07-12 β€” this tool
// used to stop at mutation labels like "W44K" with nothing
// to copy/paste or order). Falls back to the old plain-text
// summary if a variant somehow has no `dna` (best-effort
// encoding failed for that one β€” see agent_tools.py).
seqItems: variants.slice(0, 5).filter(function (v) { return v.dna; }).map(function (v) {
var muts = (v.mutations || []).join(", ") || "WT";
// mutated_positions are 0-indexed AMINO ACID positions
// (agent_tools.py) β€” codon i is nt [i*3, i*3+3), an
// exact mapping since reverse_translate() is strict
// 1:1 codon-per-residue with no leading offset.
var ranges = (v.mutated_positions || []).map(function (p) { return [p * 3, p * 3 + 3]; });
return { label: muts + " Β· fitness " + (v.fitness != null ? v.fitness : "?") +
(v.length_bp != null ? " Β· " + v.length_bp.toLocaleString() + " bp" : ""),
seq: v.dna, highlightRanges: ranges };
}),
items: variants.slice(0, 5).some(function (v) { return v.dna; }) ? [] :
variants.slice(0, 5).map(function (v) {
return [(v.mutations || []).join(", ") || "?", "fitness " + (v.fitness != null ? v.fitness : "?")];
}) };
}
// Unrecognized tool/shape β€” toolCard falls back to JSON.stringify.
return { name: label, status: "done", result: result };
}
function emptyState() {
var chips = SUGGESTIONS.map(function (s, i) {
return '<button class="asst-chip" data-sugg="' + i + '"><span class="k">' + esc(s.k) + '</span><span class="t">' + esc(s.t) + "</span></button>";
}).join("");
return '<div class="asst-empty">' +
"<h2>What are we engineering today?</h2>" +
"<p>Talk to the engine in plain language. It routes across the Design β†’ Build β†’ Edit β†’ Learn loop and runs the right tools β€” no menus to hunt through.</p>" +
'<div class="asst-chips">' + chips + "</div>" +
"</div>";
}
function setPhase(p) {
[].forEach.call(els.phaseRail.children, function (el) { el.classList.toggle("on", el.dataset.phase === p); });
}
// Hidden until a chat has an actual token count (i.e. after its first
// real reply β€” a fresh/empty conversation has nothing to show yet).
// Thresholds are conservative on purpose: a long tool-result message
// gets bundled into the NEXT request's prompt too, so usage can jump
// between renders β€” better to warn a little early than not at all.
function renderContextMeter(c) {
var limit = c && c.contextTokensLimit;
if (!limit) { els.contextMeter.hidden = true; return; }
var used = c.contextTokensUsed || 0;
var pct = Math.min(100, Math.round((used / limit) * 100));
els.contextMeter.hidden = false;
els.contextMeter.classList.toggle("warn", pct >= 70 && pct < 90);
els.contextMeter.classList.toggle("crit", pct >= 90);
els.contextFill.style.width = pct + "%";
els.contextPct.textContent = pct + "%";
}
// ── Interaction ---------------------------------------------------------
// ── Conversation history (popover anchored under the title) -------------
// Recent chats used to live in a permanent left rail β€” a second sidebar
// right next to the engine's own tool nav. Now they're a compact dropdown:
// one nav rail total, and switching chats is a click on the title away.
function openHistory() {
if (!els.topLead) return;
els.topLead.classList.add("open");
if (els.titleBtn) els.titleBtn.setAttribute("aria-expanded", "true");
}
function closeHistory() {
if (!els.topLead) return;
els.topLead.classList.remove("open");
if (els.titleBtn) els.titleBtn.setAttribute("aria-expanded", "false");
}
function toggleHistory() {
if (els.topLead && els.topLead.classList.contains("open")) closeHistory();
else openHistory();
}
// Attaches the signed-in user's Authorization header to /api/agent/*
// calls. assistant.html is a standalone document in its own iframe β€”
// it does NOT load auth.js (which wraps window.fetch globally site-
// wide), deliberately: auth.js also wires a full-page trial-timer
// modal + top-window redirect-on-402, which would fight this panel's
// own inline "Sign in" card. Reads the SAME sessionStorage key auth.js
// stashes the Supabase access token under β€” sessionStorage is shared
// across same-origin frames in one tab, so a token the top-level page
// already stashed is visible here without any extra plumbing. Without
// this, every Turing request looked anonymous to the server even for
// a signed-in user (confirmed bug, 2026-07-10).
function _authToken() {
try { return sessionStorage.getItem("td_access_token") || null; }
catch (e) { return null; }
}
function _apiFetch(url, opts) {
opts = opts || {};
var token = _authToken();
if (token) {
var headers = new Headers(opts.headers || {});
if (!headers.has("Authorization")) headers.set("Authorization", "Bearer " + token);
opts = Object.assign({}, opts, { headers: headers });
}
return fetch(url, opts);
}
// In-flight session-creation promises, keyed by chat id β€” never persisted
// (a chat can be reopened seconds after a page reload, well past any
// fetch's lifetime, and Promises don't survive JSON.stringify anyway).
// Session creation always funnels through here so a fast typist hitting
// send() right after newChat() shares the same in-flight request instead
// of opening two sessions for one conversation.
var sessionPromises = {};
function ensureSession(c) {
if (c.sessionId) return Promise.resolve(c.sessionId);
if (!sessionPromises[c.id]) {
sessionPromises[c.id] = _apiFetch("/api/agent/session", { method: "POST" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (j && j.session_id) { c.sessionId = j.session_id; c.phase = j.phase || c.phase; persist(); if (state.activeId === c.id) renderThread(); }
return c.sessionId || "";
})
.catch(function () { return c.sessionId || ""; })
.then(function (sid) { delete sessionPromises[c.id]; return sid; });
}
return sessionPromises[c.id];
}
function newChat() {
var c = { id: uid(), sessionId: null, phase: "Design", updatedAt: Date.now(), title: "New conversation", messages: [] };
state.chats.unshift(c); state.activeId = c.id; persist(); renderList(); renderThread();
scrollBottom(true); // always jump to the bottom of a freshly created (empty) chat
closeHistory();
els.input.focus();
if (PREVIEW_MODE) return; // no backend calls in preview
// Best-effort: open a real agent session ahead of the first send.
// Failure is silent β€” the UI still works and send() will retry via
// ensureSession, surfacing a clear notice only if that also fails.
ensureSession(c);
}
function selectChat(id) { state.activeId = id; renderList(); renderThread(); scrollBottom(true); closeHistory(); }
function deleteChat(id) {
// No undo, no trash β€” this is permanent the moment it's clicked.
// The rest of the engine confirms destructive actions (e.g. the
// "stop the engine server" control); a stray misclick here
// shouldn't be the one place that silently isn't.
var c = state.chats.find(function (x) { return x.id === id; });
var label = c ? ("β€œ" + c.title + "”") : "this conversation";
if (!window.confirm("Delete " + label + "? This can't be undone.")) return;
state.chats = state.chats.filter(function (x) { return x.id !== id; });
if (state.activeId === id) state.activeId = state.chats.length ? state.chats[0].id : null;
persist(); renderList(); renderThread();
}
function send() {
if (PREVIEW_MODE) return; // composer disabled in preview β€” never reaches the backend
var text = els.input.value.trim();
if (!text || state.busy) return;
if (text.length > MESSAGE_MAX_CHARS) return; // send button is already disabled for this; belt-and-suspenders
if (!state.activeId) newChat();
var c = active();
c.messages.push({ role: "user", text: text });
if (c.title === "New conversation") c.title = text.length > 52 ? text.slice(0, 52) + "…" : text;
c.updatedAt = Date.now();
els.input.value = ""; autoGrow(); persist(); renderList(); renderThread();
state.busy = true; els.send.disabled = true;
showThinking(text);
// Set only on a successful text reply β€” {chat, index, text}. Pushed
// with empty text below, then animated in via revealText() once the
// render at the end of this chain creates its DOM node, so the
// reply appears to type rather than pop in all at once.
var pendingReply = null;
function stepOnce(sessionId) {
return _apiFetch("/api/agent/step", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: sessionId, message: text }),
}).then(function (r) {
var retry = parseInt(r.headers.get("Retry-After") || "", 10);
return r.json().catch(function () { return {}; }).then(function (j) {
return { ok: r.ok, status: r.status, retry: retry, j: j };
});
});
}
ensureSession(c)
.then(function (sessionId) { return stepOnce(sessionId); })
.then(function (res) {
if (res.j && res.j.kind === "session_expired") {
// Server memory (not a database) lost this session, most
// likely a deploy restarted the worker since it was
// created β€” invisible to the user. Recover transparently:
// fresh session, retry this exact message once. If the
// retry ALSO comes back session_expired, something else is
// wrong and it falls through to the generic error branch
// below rather than retrying forever.
c.sessionId = null;
return ensureSession(c).then(function (sid2) { return stepOnce(sid2); });
}
return res;
})
.then(function (res) {
var j = res.j || {};
if (res.status === 429 || j.kind === "rate_limited") {
// Server-side rate limit β€” pace the user, never error out.
var secs = res.retry > 0 ? res.retry : 30;
c.messages.push({ role: "system", text: "Usage limit reached. Try again in about " + secs + "Β s." });
lockComposer(secs);
} else if (j.kind === "chat_limit") {
// Daily message budget exhausted (credit system) β€” offer Pro.
c.messages.push({ role: "limit",
text: j.credits_limit ? ("You've used today's " + j.credits_limit + " assistant messages.") : "You've reached today's assistant message limit.",
resets_in_s: j.resets_in_s });
} else if (res.status === 402 || j.kind === "auth_required") {
// Anonymous trial window expired (dee/auth.py's time-based
// gate) β€” a real, expected state, not a backend hiccup, so
// it gets its own honest message + direct sign-in link
// instead of falling into the generic error bubble below.
c.messages.push({ role: "signin",
text: j.error || "Your free trial has ended. Sign in to keep going.",
signin_url: j.signup_url || "https://turingdna.com/signin/?from=assistant" });
} else if (j.kind === "agent_unavailable") {
// Model backend isn't reachable right now β€” missing config
// or the upstream account is out of credits. Deliberately
// a fixed, calm message rather than j.error: never surface
// "insufficient credits"/billing detail to end users, and
// this isn't a "retry in a second" hiccup like the generic
// branch below implies.
c.messages.push({ role: "system", text: "Turing is still in active development and will be back shortly." });
} else if (j.kind === "message_too_long") {
// Belt-and-suspenders β€” the composer already blocks sending
// past MESSAGE_MAX_CHARS, so this only fires if that check
// was somehow bypassed (stale cached JS, etc.). Server's own
// message already explains it clearly.
c.messages.push({ role: "system", text: j.error });
} else if (!res.ok || j.error) {
c.messages.push({ role: "system", text: "Turing hit a snag reaching the model β€” try again in a moment. (" + (j.error || "unavailable") + ")" });
} else {
if (j.phase) c.phase = j.phase;
if (j.context_tokens_limit) { c.contextTokensUsed = j.context_tokens_used || 0; c.contextTokensLimit = j.context_tokens_limit; }
if (j.tool_result) c.messages.push({ role: "tool", tool: shapeToolResult(j.tool_name, j.tool_result) });
pendingReply = { chat: c, index: c.messages.length, text: j.assistant_message || "(no reply)" };
c.messages.push({ role: "assistant", text: "" });
}
})
.catch(function () {
c.messages.push({ role: "system", text: "Couldn't reach the assistant backend β€” check your connection and try again." });
})
.then(function () {
stopThinking(); // clear the caption cycle before this node is torn down below
state.busy = false;
if (!composerLocked()) els.send.disabled = els.input.value.trim() === "";
c.updatedAt = Date.now(); persist(); renderList(); renderThread();
if (!pendingReply) return;
var bodies = els.inner.querySelectorAll(".asst-turn.assistant .asst-body");
var node = bodies[bodies.length - 1];
var finish = function () {
pendingReply.chat.messages[pendingReply.index].text = pendingReply.text;
persist();
// Announced once, on completion β€” not wired into the
// thread's own render (see the liveRegion comment in
// assistant.html for why). Skipped if the user has since
// switched to a different chat; announcing a reply to a
// conversation they're not even looking at would be
// confusing, not helpful.
if (els.liveRegion && state.activeId === pendingReply.chat.id) {
els.liveRegion.textContent = "TuringDNA: " + pendingReply.text;
}
};
// No node means the user already switched away from this chat
// before the render above ran β€” just commit the text silently
// so it's there whenever they come back; nothing to animate.
if (node) revealText(node, pendingReply.text).then(finish);
else finish();
});
}
// Reveals `fullText` into `el` progressively instead of setting it
// instantly β€” the Claude/ChatGPT-style "typing" effect. Re-runs
// formatMessageText() on each growing prefix of the PLAIN text (not a
// substring of the final HTML) each tick, so paragraphs/inline-code/
// fences still format correctly mid-reveal instead of flashing a
// half-open tag. An in-progress, not-yet-closed fence briefly shows its
// raw backticks until the closing ``` arrives a few ticks later β€” same
// class of transient artifact as a half-typed inline-code span, and
// self-corrects on its own.
function revealText(el, fullText) {
var CHARS_PER_TICK = 3, TICK_MS = 15;
var i = 0;
return new Promise(function (resolve) {
(function tick() {
i = Math.min(fullText.length, i + CHARS_PER_TICK);
el.innerHTML = formatMessageText(fullText.slice(0, i));
scrollBottom();
if (i < fullText.length) setTimeout(tick, TICK_MS);
else resolve();
})();
});
}
// _thinkTrack/_thinkChatId are read by renderThread() itself (see the
// "state.busy" check near its end) so the indicator survives ANY
// render that happens to fire mid-flight β€” not just the one showThinking()
// triggers. That mid-flight re-render is real, not hypothetical:
// ensureSession()'s own success callback calls renderThread() the
// moment a fresh session is created, which for a brand-new conversation
// resolves fast (a lightweight POST, no LLM involved) β€” well before the
// actual /api/agent/step reply comes back. Before this fix, that second
// render wiped the freshly-appended thinking node straight back out,
// leaving a dead, silent gap for however long the real call took β€”
// exactly the "no preview" experience reported 2026-07-11. _thinkChatId
// scopes this to the chat that's actually busy: state.busy is a single
// global flag (the composer already blocks a second concurrent send),
// but the user can still switch to a DIFFERENT, idle chat mid-flight β€”
// that chat's render must NOT get a thinking bubble it never asked for.
var _thinkTrack = null, _thinkChatId = null, _thinkStep = 0, _thinkTimer = null;
function stopThinking() {
if (_thinkTimer) { clearInterval(_thinkTimer); _thinkTimer = null; }
_thinkTrack = null; _thinkChatId = null; _thinkStep = 0;
if (els.phaseRail) els.phaseRail.classList.remove("busy");
}
function appendThinkingNode() {
var steps = THINKING_STEPS[_thinkTrack];
var node = document.createElement("div");
node.className = "asst-turn assistant";
node.innerHTML = '<div class="asst-role"><span class="dot"></span>TuringDNA</div>' +
'<div class="asst-body"><div class="asst-thinking">' +
'<span class="asst-think"><span></span><span></span><span></span></span>' +
'<span class="asst-think-caption">' + esc(steps[_thinkStep % steps.length]) + "</span>" +
"</div></div>";
els.inner.appendChild(node);
}
function showThinking(userText) {
stopThinking(); // clear any previous cycle before this one starts
var c = active(); if (!c) return;
_thinkTrack = guessTrack(userText);
_thinkChatId = c.id;
if (els.phaseRail) els.phaseRail.classList.add("busy"); // pulses the current phase pill
renderThread(); // state.busy is already true here β€” appends the node itself
scrollBottom();
_thinkTimer = setInterval(function () {
_thinkStep++;
// Fresh lookup every tick instead of closing over the node
// created above β€” a mid-flight renderThread() (see comment
// above) creates a NEW node each time, and a stale closure
// reference would silently keep animating a detached one.
var cap = els.inner.querySelector(".asst-think-caption");
if (!cap || !_thinkTrack) { stopThinking(); return; }
cap.textContent = THINKING_STEPS[_thinkTrack][_thinkStep % THINKING_STEPS[_thinkTrack].length];
}, 1400);
}
// ── Composer plumbing ---------------------------------------------------
function autoGrow() {
els.input.style.height = "auto";
els.input.style.height = Math.min(els.input.scrollHeight, 180) + "px";
var len = els.input.value.length;
var overLimit = len > MESSAGE_MAX_CHARS;
els.send.disabled = els.input.value.trim() === "" || state.busy || composerLocked() || overLimit;
// Only shown once it's actually relevant β€” no point cluttering the
// composer under a normal-length message.
if (els.charCount) {
var showAt = MESSAGE_MAX_CHARS - 500;
if (len < showAt) {
els.charCount.hidden = true;
} else {
els.charCount.hidden = false;
els.charCount.classList.toggle("over", overLimit);
els.charCount.classList.toggle("warn", !overLimit && len >= MESSAGE_MAX_CHARS - 200);
els.charCount.textContent = overLimit
? (len - MESSAGE_MAX_CHARS).toLocaleString() + " over the " + MESSAGE_MAX_CHARS.toLocaleString() + "-character limit β€” trim before sending"
: len.toLocaleString() + " / " + MESSAGE_MAX_CHARS.toLocaleString();
}
}
}
// Unforced calls only scroll if the user was already near the bottom β€”
// every render used to jump straight to the bottom unconditionally,
// which yanks a user who scrolled up mid-conversation to reread
// something back down the instant anything re-renders (every tick of
// the typewriter reveal included). force=true is for the moments that
// really should always jump (switching to a different chat, starting
// a new one) β€” checked AFTER any DOM update already happened is fine,
// since scrollTop doesn't move on its own when content is appended.
function scrollBottom(force) {
var el = els.thread;
var nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (force || nearBottom) el.scrollTop = el.scrollHeight;
}
// ── Rate-limit composer lock: disable input for `secs`, counting down in
// the hint line, then restore. Honors the server's Retry-After.
function composerLocked() { return Date.now() < (state.lockUntil || 0); }
function lockComposer(secs) {
state.lockUntil = Date.now() + secs * 1000;
els.input.disabled = true; els.send.disabled = true;
(function tick() {
var left = Math.ceil((state.lockUntil - Date.now()) / 1000);
if (left <= 0) {
els.input.disabled = false;
if (els.hint) els.hint.innerHTML = DEFAULT_HINT;
autoGrow();
return;
}
if (els.hint) els.hint.textContent = "Usage limit Β· resuming in " + left + " s";
setTimeout(tick, 500);
})();
}
// ── Helpers -------------------------------------------------------------
function uid() { return "c_" + Math.random().toString(36).slice(2, 10); }
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]; }); }
// Light markdown: paragraphs, `inline code`, **bold**, *italic*,
// '# '/'## ' headings, and '* '/'- ' bullet lists. Content is escaped
// first, so this only ever turns OUR inserted tags into HTML β€” model
// text can never inject markup. The model routinely emits real
// markdown (and occasionally a LaTeX arrow macro) even though this
// panel has no markdown/LaTeX renderer, so unhandled syntax used to
// leak as literal characters β€” "### ", "**", "$\rightarrow$" all shown
// raw (reported 2026-07-11, same report as the sequence-formatting
// bug). System prompt now also tells the model to stick to this
// subset; this is the client-side safety net for whatever still slips
// through.
function fmt(s) {
s = esc(s).replace(/\$\\rightarrow\$|\\rightarrow/g, "β†’");
var lines = s.split("\n");
var html = "", para = [], list = [];
function flushPara() { if (para.length) { html += "<p>" + para.join("<br>") + "</p>"; para = []; } }
function flushList() { if (list.length) { html += "<ul>" + list.join("") + "</ul>"; list = []; } }
function inline(t) {
return t.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
}
lines.forEach(function (line) {
var h = line.match(/^(#{1,4})\s+(.*)$/);
var b = line.match(/^[*-]\s+(.*)$/);
if (h) { flushPara(); flushList(); html += '<p class="asst-h">' + inline(h[2]) + "</p>"; }
else if (b) { flushPara(); list.push("<li>" + inline(b[1]) + "</li>"); }
else if (line.trim() === "") { flushPara(); flushList(); }
else { flushList(); para.push(inline(line)); }
});
flushPara(); flushList();
return html;
}
// A long unbroken run of letters (a pasted or generated DNA/RNA/protein
// sequence β€” no real English word runs 60+ characters with no
// whitespace) gets pulled out and rendered as a collapsed, expandable
// block instead of wrapping raw inline with the rest of the message.
// Without this, a sequence + surrounding prose read as one unreadable
// wall of text (reported 2026-07-11). Applied to BOTH user and
// assistant text β€” the model routinely answers with a full DNA
// sequence per variant, not just short summary fields.
var SEQ_RE = /[A-Za-z*]{60,}/g;
// The model also wraps sequences in fenced code blocks (```text\n...\n```).
// fmt() only understands single-backtick inline code, so a fence used to
// leak its backtick markers as literal text AND, worse, dump the whole
// sequence into one unbroken inline <code> with no wrap rule β€” forcing
// the message wide and requiring horizontal scroll (reported
// 2026-07-11, screenshot showed both symptoms together). Fences are
// parsed out first; a long sequence inside one collapses exactly like a
// pasted sequence, anything shorter renders as a normal wrapped block.
var FENCE_RE = /```[a-zA-Z0-9_-]*\n?([\s\S]*?)```/g;
var _seqStore = {}; // seq element id -> full sequence text, for the toggle handler
var _seqIdCounter = 0;
function formatMessageText(s) {
s = String(s == null ? "" : s);
FENCE_RE.lastIndex = 0;
if (!FENCE_RE.test(s)) return formatProse(s); // common case β€” no fence, skip the split work
FENCE_RE.lastIndex = 0;
var html = "", last = 0, m;
while ((m = FENCE_RE.exec(s))) {
if (m.index > last) html += formatProse(s.slice(last, m.index));
html += fencedBlock(m[1]);
last = m.index + m[0].length;
}
if (last < s.length) html += formatProse(s.slice(last));
return html;
}
function fencedBlock(raw) {
var trimmed = raw.replace(/\n$/, "");
var collapsed = trimmed.replace(/\s+/g, "");
if (collapsed.length >= 60 && /^[A-Za-z*]+$/.test(collapsed)) return seqBlock(collapsed);
return '<pre class="asst-code">' + esc(trimmed) + "</pre>";
}
// A paste where every line is itself sequence-looking is conventional
// FASTA-style line wrapping (typically 60-80 chars/line) β€” ONE sequence
// that happens to be pre-wrapped, not N separate ones. SEQ_RE alone
// can't see this: a character class never matches across the newline
// between lines, so each wrapped line tripped its own seqBlock β€” a
// routine multi-line paste rendered as a tall stack of near-identical
// "Show full" rows (reported 2026-07-12). Joining same-paragraph
// sequence lines back together BEFORE the scan below fixes it at the
// source: the merged text has one long unbroken run, so it becomes one
// block. The wrap was a paste-time artifact, not part of the sequence,
// so dropping it also makes the "Show full" text a clean, continuous,
// actually-copyable sequence instead of one with embedded line breaks.
var SEQ_LINE_RE = /^[A-Za-z*]{20,}$/;
function mergeWrappedSequenceParagraphs(s) {
// Scans every line (not per-paragraph β€” a paragraph containing one
// leading prose line + the wrapped sequence would otherwise fail an
// "every line qualifies" check and never merge at all). Consecutive
// sequence-looking lines accumulate into one run and get joined with
// no separator when the run ends (a non-sequence line, a blank
// line, or the end of input); everything else passes through
// untouched, so surrounding prose and genuine paragraph breaks
// between two SEPARATE pastes are both preserved.
var lines = s.split("\n");
var out = [];
var run = [];
function flushRun() {
if (run.length >= 2) out.push(run.join(""));
else if (run.length === 1) out.push(run[0]);
run = [];
}
lines.forEach(function (line) {
if (SEQ_LINE_RE.test(line.trim())) {
run.push(line.trim());
} else {
flushRun();
out.push(line);
}
});
flushRun();
return out.join("\n");
}
function formatProse(s) {
s = mergeWrappedSequenceParagraphs(s);
SEQ_RE.lastIndex = 0;
if (!SEQ_RE.test(s)) return fmt(s); // common case β€” no bare sequence, skip the split work
SEQ_RE.lastIndex = 0;
var html = "", last = 0, m;
while ((m = SEQ_RE.exec(s))) {
if (m.index > last) html += fmt(s.slice(last, m.index));
html += seqBlock(m[0]);
last = m.index + m[0].length;
}
if (last < s.length) html += fmt(s.slice(last));
return html;
}
function seqPreview(full) {
return full.length > 60 ? full.slice(0, 36) + "…" + full.slice(-10) : full;
}
// Renders `seq` as escaped HTML with `ranges` ([start,end) pairs, 0-
// indexed, half-open) wrapped in a highlight span. Ranges are the
// MUTATED CODON positions (see mutated_positions in agent_tools.py) β€”
// never a naive diff against a WT string, which would also catch
// unrelated silent codon differences from independent restriction-site
// scrubbing and mislabel them as "the mutation."
function seqHtmlWithHighlights(seq, ranges) {
if (!ranges || !ranges.length) return esc(seq);
var sorted = ranges.slice().sort(function (a, b) { return a[0] - b[0]; });
var html = "", last = 0;
sorted.forEach(function (r) {
var start = Math.max(last, r[0]), end = Math.min(seq.length, r[1]);
if (start >= end) return;
if (start > last) html += esc(seq.slice(last, start));
html += '<mark class="asst-seq-mut">' + esc(seq.slice(start, end)) + "</mark>";
last = end;
});
if (last < seq.length) html += esc(seq.slice(last));
return html;
}
// Client-side only β€” the sequence text is already in the browser, no
// server round-trip needed. Mirrors the Blob-download pattern already
// used elsewhere in the engine (app.js's export/save flows).
function downloadFasta(label, seq) {
var header = String(label || "sequence").replace(/\s+/g, "_").slice(0, 80);
var body = ">" + header + "\n" + seq.replace(/(.{70})/g, "$1\n").replace(/\n$/, "") + "\n";
var blob = new Blob([body], { type: "text/x-fasta" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url; a.download = header.replace(/[^A-Za-z0-9_.-]/g, "_") + ".fasta";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
// opts.label becomes the FASTA header + suggested filename for the
// download button β€” e.g. a DE variant passes its mutation list so the
// downloaded file is self-describing, not "sequence.fasta" x N when a
// reply has several variants.
// opts.highlightRanges marks changed codons red (see
// seqHtmlWithHighlights). A block with highlights starts EXPANDED β€”
// showing red text inside a collapsed, truncated preview would usually
// just hide the very thing being pointed out, since the mutated codon
// is rarely within the first-36/last-10-char snippet.
function seqBlock(seq, opts) {
opts = opts || {};
var id = "seq" + (_seqIdCounter++);
var ranges = opts.highlightRanges || null;
_seqStore[id] = { seq: seq, label: opts.label || "sequence", ranges: ranges };
var startExpanded = !!(ranges && ranges.length);
var previewHtml = startExpanded ? seqHtmlWithHighlights(seq, ranges) : esc(seqPreview(seq));
return '<div class="asst-seq' + (startExpanded ? " expanded" : "") + '" data-seq-id="' + id + '">' +
'<code class="asst-seq-preview">' + previewHtml + "</code>" +
'<span class="asst-seq-meta">' + seq.length.toLocaleString() + " chars</span>" +
'<button class="asst-seq-copy" type="button" data-seq-copy="' + id + '" title="Copy sequence">Copy</button>' +
'<button class="asst-seq-dl" type="button" data-seq-dl="' + id + '" title="Download FASTA">↓ FASTA</button>' +
'<button class="asst-seq-toggle" type="button" data-seq-toggle="' + id + '">' + (startExpanded ? "Show less" : "Show full") + "</button>" +
"</div>";
}
// ── Wire events ---------------------------------------------------------
els.newBtn.addEventListener("click", newChat);
els.send.addEventListener("click", send);
els.input.addEventListener("input", autoGrow);
els.input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
els.list.addEventListener("click", function (e) {
var del = e.target.closest("[data-del]");
if (del) { e.stopPropagation(); deleteChat(del.getAttribute("data-del")); return; }
var row = e.target.closest("[data-id]");
if (row) selectChat(row.getAttribute("data-id"));
});
// .asst-chat rows are role="button" tabindex="0" but plain <div>s β€” a
// real <button> gets Enter/Space activation for free from the browser,
// a div with an ARIA button role does not; that has to be wired up by
// hand or the row is keyboard-focusable but not keyboard-usable.
els.list.addEventListener("keydown", function (e) {
if (e.key !== "Enter" && e.key !== " ") return;
var row = e.target.closest("[data-id]");
if (!row) return;
e.preventDefault();
selectChat(row.getAttribute("data-id"));
});
els.inner.addEventListener("click", function (e) {
var chip = e.target.closest("[data-sugg]");
if (chip) { els.input.value = SUGGESTIONS[+chip.getAttribute("data-sugg")].t; autoGrow(); els.input.focus(); }
var toggle = e.target.closest("[data-seq-toggle]");
if (toggle) {
var id = toggle.getAttribute("data-seq-toggle");
var stored = _seqStore[id] || {};
var full = stored.seq || "";
var box = toggle.closest(".asst-seq");
var preview = box.querySelector(".asst-seq-preview");
var expanded = box.classList.toggle("expanded");
// Highlights only render in the expanded view β€” collapsing
// truncates to a plain snippet, same as any other sequence.
preview.innerHTML = expanded ? seqHtmlWithHighlights(full, stored.ranges) : esc(seqPreview(full));
toggle.textContent = expanded ? "Show less" : "Show full";
}
var copyBtn = e.target.closest("[data-seq-copy]");
if (copyBtn) {
var entry = _seqStore[copyBtn.getAttribute("data-seq-copy")];
if (entry && navigator.clipboard) {
navigator.clipboard.writeText(entry.seq).then(function () {
var orig = copyBtn.textContent;
copyBtn.textContent = "Copied";
setTimeout(function () { copyBtn.textContent = orig; }, 1400);
}).catch(function () {});
}
}
var dlBtn = e.target.closest("[data-seq-dl]");
if (dlBtn) {
var toDl = _seqStore[dlBtn.getAttribute("data-seq-dl")];
if (toDl) downloadFasta(toDl.label, toDl.seq);
}
});
// History popover: click the title to toggle, click anywhere else (or
// Escape) to close. stopPropagation on the trigger so its own click
// doesn't immediately re-trigger the document-level outside-click check.
if (els.titleBtn) els.titleBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleHistory(); });
document.addEventListener("click", function (e) {
if (!els.topLead || !els.topLead.classList.contains("open")) return;
if (e.target.closest("#historyMenu") || e.target.closest("#titleBtn")) return;
closeHistory();
});
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeHistory(); });
// Live theme sync: assistant-boot.js only sets <html data-theme> once,
// pre-paint, from localStorage β€” so flipping the engine's theme toggle
// while this panel is already open (embedded in an iframe) never reached
// it. The parent (app.js) posts { type: "td-theme" } on every toggle;
// apply it here so the panel follows along live instead of needing a reload.
window.addEventListener("message", function (e) {
// The only legitimate sender is app.js on the engine's own page β€”
// it explicitly targets window.location.origin when it posts this
// (see app.js's postMessage call). Any other origin is rejected
// before even checking the payload shape β€” postMessage delivery
// itself doesn't enforce this, the receiver has to.
if (e.origin !== window.location.origin) return;
if (!e.data || e.data.type !== "td-theme") return;
var theme = e.data.theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem("td-theme", theme); } catch (err) {}
});
// ── Boot ----------------------------------------------------------------
load();
state.activeId = state.chats.length ? state.chats.slice().sort(function (a, b) { return b.updatedAt - a.updatedAt; })[0].id : null;
renderList(); renderThread(); autoGrow();
// `?seed=<prompt>`: Mission Control's "What are we engineering today?" input
// reloads this iframe with the typed prompt so the composer opens pre-filled
// (not auto-sent β€” the user still hits send). Purely a convenience prefill.
try {
var seed = new URLSearchParams(location.search).get("seed");
if (seed && els.input && !PREVIEW_MODE) {
els.input.value = seed.slice(0, MESSAGE_MAX_CHARS);
autoGrow();
if (els.send) els.send.disabled = els.input.value.trim() === "";
els.input.focus();
}
} catch (e) {}
// Preview: lock the composer so nothing is sent, but keep the UI fully
// browsable (recents, phase rail, demo sessions all interactive).
if (PREVIEW_MODE) {
if (els.previewBar) els.previewBar.hidden = false;
els.input.disabled = true;
els.send.disabled = true;
els.input.placeholder = "Preview β€” Turing isn't live yet. Browse the example sessions to see how it works.";
if (els.hint) els.hint.innerHTML = "This is a preview of <b>Turing</b> Β· the composer turns on when the engine's agent goes live";
}
})();