Quillwright / quillwright /web /js /workspace.js
Aarya2004
Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)
47b2a99
Raw
History Blame Contribute Delete
26.5 kB
// Page logic: wire the Forge button, render the estimate, handle events.
import {
forgeEstimateStream,
resumeEstimateStream,
uploadImage,
recalc,
downloadPdf,
downloadJson,
sendEstimate,
translateEstimate,
chatAboutEstimate,
transcribeNote,
parseDocument,
modelInfo,
saveEstimate,
loadEstimate,
deleteEstimate,
createPairing,
pollPairing,
} from "./client.js";
import { resetTrace, addStep } from "./trace.js";
import { escapeHtml } from "./util.js";
const $ = (id) => document.getElementById(id);
const TAX_RATE = 0.13;
const JOB_TITLE = "AC Unit Repair — 123 Maple St";
// Run state, in one place: the label text + the live-dot color (data-state).
function setState(state, label) {
$("forge-state").textContent = label;
document.querySelector(".live").dataset.state = state;
}
// Photos picked for this job: server-side paths (after upload).
let imagePaths = [];
// Current estimate rows (editable). [{description, quantity, unit, rate, subtotal}]
let rows = [];
// English source descriptions, so language switches re-translate from English.
let sourceDescriptions = [];
// ADR-0013: the sanitized post-forge chat turns, and this estimate's id in the store
// (null until it has been forged/saved). Thread + id travel together on save/reopen.
let refinementThread = [];
let savedId = null;
// A rate change awaiting a scope answer ("this estimate"/"the catalog") from the next turn.
let pendingChange = null;
// Model mode from /api/model_info (stub | local | modal | mixed) + whether we've forged
// once this session — used to warn about the GPU cold-start on the first real forge.
let modelMode = "stub";
let firstForgeDone = false;
let warmupPending = false; // a "waking the models" card is showing, awaiting the first step
function readAsDataURL(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
});
}
// --- Voice note: record via mic, transcribe (Cohere Transcribe), fill the note ---
let mediaRecorder = null;
let recordedChunks = [];
async function toggleRecording() {
const btn = $("mic-btn");
// Stop if already recording.
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
btn.title = "Mic permission denied";
return;
}
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => e.data.size && recordedChunks.push(e.data);
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
btn.classList.remove("recording");
btn.innerHTML = '<span class="material-symbols-outlined">hourglass_top</span>';
const blob = new Blob(recordedChunks, { type: "audio/webm" });
const dataUrl = await readAsDataURL(blob);
const text = await transcribeNote(dataUrl, "note.webm");
if (text) $("transcript").value = text;
btn.innerHTML = '<span class="material-symbols-outlined">mic</span>';
};
mediaRecorder.start();
btn.classList.add("recording");
btn.innerHTML = '<span class="material-symbols-outlined">stop</span>';
}
async function onPhotos(e) {
const files = Array.from(e.target.files || []);
for (const file of files) {
const dataUrl = await readAsDataURL(file);
const img = document.createElement("img");
img.src = dataUrl;
$("thumbs").appendChild(img);
const path = await uploadImage(dataUrl, file.name);
imagePaths.push(path);
}
}
function money(n) {
return `$${Number(n).toFixed(2)}`;
}
// --- Document Capture (ADR-0011): a handed-over document -> Proposed Line Items ---
// The document is the *source*, but every price it carries is proposed, never a
// fact — the human confirms (or edits) each row before it enters the estimate.
async function onDocument(e) {
const file = (e.target.files || [])[0];
e.target.value = ""; // allow re-picking the same file
if (!file) return;
setState("working", "Reading document…");
const dataUrl = await readAsDataURL(file);
const out = await parseDocument(dataUrl, file.name);
if (rows.length) setState("done", "Done");
else setState("idle", "Idle");
const title = (out.observations[0] || {}).text || file.name;
addStep($("log"), {
action: "document",
model: out.model,
detail: `“${title}” — ${out.proposed_items.length} priced row(s) proposed`,
status: "ok",
});
showProposedItems(out.proposed_items);
}
// Render the Proposed-Line-Items confirm card on the Agent-Pause surface.
function showProposedItems(items) {
const card = $("pause");
if (!items.length) {
card.innerHTML = `
<div class="bot"><span class="material-symbols-outlined">document_scanner</span></div>
<div class="doc-confirm">
<p>I couldn't find any priced rows in that document.</p>
<div class="opts"><button class="link-btn" id="doc-dismiss">Dismiss</button></div>
</div>`;
} else {
card.innerHTML = `
<div class="bot"><span class="material-symbols-outlined">document_scanner</span></div>
<div class="doc-confirm">
<p>I read ${items.length} priced row(s) off that document. Confirm what enters the estimate:</p>
<div class="doc-items">
${items
.map(
(p, i) => `
<label class="doc-item" style="--doc-delay:${i * 55}ms">
<input type="checkbox" data-i="${i}" checked />
<span class="doc-desc">${escapeHtml(p.description)}<small>${escapeHtml(p.source_text)}</small></span>
<input class="doc-qty" type="number" step="any" value="${p.quantity}" aria-label="Quantity" />
<input class="doc-rate" type="number" step="0.01" value="${p.rate.toFixed(2)}" aria-label="Rate" />
</label>`,
)
.join("")}
</div>
<div class="opts">
<button class="btn btn--primary" id="doc-add">Add to estimate</button>
<button class="link-btn" id="doc-dismiss">Dismiss</button>
</div>
</div>`;
}
card.style.display = "flex";
const hide = () => {
card.style.display = "none";
card.innerHTML = "";
};
$("doc-dismiss").addEventListener("click", hide);
const add = $("doc-add");
if (add)
add.addEventListener("click", () => {
card.querySelectorAll(".doc-item").forEach((row) => {
const check = row.querySelector("input[type=checkbox]");
if (!check.checked) return;
const p = items[Number(check.dataset.i)];
rows.push({
description: p.description,
quantity: parseFloat(row.querySelector(".doc-qty").value) || 1,
unit: p.unit,
rate: parseFloat(row.querySelector(".doc-rate").value) || 0,
subtotal: 0,
price_source: "document",
});
});
hide();
recalcFromRows();
setChatEnabled(rows.length > 0);
});
}
// Render the editable estimate from `rows`. qty/rate cells edit -> recalc.
// `animate` staggers the rows in (used when a fresh estimate lands, not on edits).
function renderRows(animate = false) {
const tbody = $("est-rows");
if (!rows.length) {
tbody.innerHTML =
'<tr class="table-empty"><td colspan="4">No line items yet — forge an estimate to begin.</td></tr>';
return;
}
tbody.innerHTML = rows
.map(
(
li,
i,
) => `<tr data-i="${i}"${animate ? ' class="row-enter" style="--row-delay:' + i * 55 + 'ms"' : ""}>
<td class="desc" contenteditable data-field="description">${li.description}</td>
<td class="num" contenteditable data-field="quantity">${li.quantity}</td>
<td class="num" contenteditable data-field="rate">${money(li.rate)}</td>
<td class="num">${money(li.subtotal)}</td>
</tr>`,
)
.join("");
}
// Pulse the rate cell of the row matching `description` — used when the apprentice
// applies a user-confirmed rate via chat, so the eye lands on what just changed.
function pulseRateCell(description) {
const i = rows.findIndex((r) => r.description === description);
if (i < 0) return;
const cell = document.querySelector(`#est-rows tr[data-i="${i}"] td[data-field="rate"]`);
if (!cell) return;
cell.classList.remove("cell-pulse");
void cell.offsetWidth; // restart the animation if it was already applied
cell.classList.add("cell-pulse");
}
// Flash the total when it changes (subtle "the number moved" cue).
let lastTotal = null;
function bumpTotal() {
const el = $("sum-total");
el.classList.remove("bump");
void el.offsetWidth; // restart the animation
el.classList.add("bump");
}
function renderTotals(est) {
$("sum-subtotal").textContent = money(est.subtotal);
$("sum-tax-rate").textContent = `${Math.round(est.tax_rate * 100)}%`;
$("sum-tax").textContent = money(est.tax);
$("sum-total").textContent = money(est.total);
if (lastTotal !== null && est.total !== lastTotal) bumpTotal();
lastTotal = est.total;
}
// Adopt a server estimate as the editable working copy.
// `animate` staggers the rows in (true when a forge/chat just produced it).
function setEstimate(est, animate = false) {
if (!est) {
rows = [];
lastTotal = null;
renderRows();
renderTotals({ subtotal: 0, tax_rate: 0, tax: 0, total: 0 });
setChatEnabled(false);
return;
}
rows = est.line_items.map((li) => ({ ...li }));
sourceDescriptions = rows.map((li) => li.description);
$("lang").value = "English";
renderRows(animate);
renderTotals(est);
setChatEnabled(rows.length > 0);
}
// Re-render the customer copy in the selected language (descriptions only).
async function onLanguageChange() {
const language = $("lang").value;
// translate from the English source each time, not from a prior translation
const src = rows.map((li, i) => ({
...li,
description: sourceDescriptions[i] ?? li.description,
}));
const est = await translateEstimate(src, JOB_TITLE, TAX_RATE, language);
rows = est.line_items.map((li) => ({ ...li }));
renderRows();
renderTotals(est);
}
// Recompute totals server-side after an edit (Facts-from-Tools).
async function recalcFromRows() {
const est = await recalc(rows, JOB_TITLE, TAX_RATE);
rows = est.line_items.map((li) => ({ ...li }));
renderRows();
renderTotals(est);
}
// Read an edited cell back into `rows`.
function onCellEdit(e) {
const td = e.target.closest("td[data-field]");
if (!td) return;
const tr = td.closest("tr");
const i = Number(tr.dataset.i);
const field = td.dataset.field;
let val = td.textContent.trim();
if (field === "quantity" || field === "rate") val = parseFloat(val.replace(/[^0-9.]/g, "")) || 0;
rows[i][field] = val;
recalcFromRows();
}
// The single event handler used by both the initial run and the resume.
function handleEvent(event) {
// The first real signal means the models are warm — mark the warm-up card done.
if (warmupPending && (event.type === "trace" || event.type === "estimate")) {
warmupPending = false;
addStep($("log"), {
action: "warmup",
model: "",
detail: "Models are warm.",
status: "ok",
});
setState("working", "Working…");
}
if (event.type === "trace") {
addStep($("log"), event.step);
} else if (event.type === "pause") {
showPause(event);
} else if (event.type === "estimate") {
setEstimate(event.estimate, true);
setState("done", "Done");
}
}
// Render the Agent-Pause question card; answering resumes the run.
function showPause(event) {
setState("needs-you", "Needs you");
const card = $("pause");
card.innerHTML = `
<div class="bot"><span class="material-symbols-outlined">smart_toy</span></div>
<div class="pause-body">
<p>${event.reason}. What should I charge for it?</p>
<div class="opts">
<input id="pause-price" class="pause-price" type="number" step="0.01" placeholder="0.00" />
<button class="btn btn--primary" id="pause-submit">Use this price</button>
</div>
</div>`;
card.style.display = "flex";
const input = $("pause-price");
input.focus();
const submit = async () => {
const value = parseFloat(input.value);
if (Number.isNaN(value)) return;
card.style.display = "none";
card.innerHTML = "";
setState("working", "Working…");
await withForgeLocked(() => resumeEstimateStream(value, handleEvent));
};
$("pause-submit").addEventListener("click", submit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
}
// Hold the Forge button down while a run streams (no double-forge); always release.
async function withForgeLocked(run) {
$("forge-btn").disabled = true;
try {
await run();
} finally {
$("forge-btn").disabled = false;
}
}
async function forge() {
const transcript = $("transcript").value;
resetTrace($("log"));
setEstimate(null);
$("pause").style.display = "none";
setState("working", "Working…");
// First real forge pays a GPU cold-start (Modal apps scale to zero; the 30B brain can
// take a minute or two to wake). Say so honestly so a slow first run reads as warming,
// not hung. Stub mode is instant, so only warn when real models are actually in play.
if (!firstForgeDone && modelMode !== "stub") {
setState("working", "Warming models…");
warmupPending = true;
addStep($("log"), {
action: "warmup",
model: modelMode === "modal" || modelMode === "mixed" ? "Modal GPU" : "local models",
detail:
"First run wakes the models — this can take a minute or two on a cold start. " +
"It's working, not stuck.",
status: "active", // pulses until the first real step lands (then marked done)
});
}
firstForgeDone = true;
await withForgeLocked(() => forgeEstimateStream(transcript, "hvac", imagePaths, handleEvent));
}
function newEstimate() {
resetTrace($("log"));
setEstimate(null);
$("pause").style.display = "none";
setState("idle", "Idle");
$("transcript").value = "";
$("thumbs").innerHTML = "";
imagePaths = [];
chatStarted = false;
refinementThread = [];
savedId = null;
pendingChange = null;
$("transcript").focus();
}
// Discard the current draft: delete its saved row (if any), then clear the workspace.
async function discardDraft() {
if (savedId) {
try {
await deleteEstimate(savedId);
} catch {
/* best-effort */
}
}
newEstimate();
}
// --- Chat: refine the estimate, in the SAME stream as the trace ---
// The input docks at the bottom of the Apprentice pane and only enables once an
// estimate exists. The first chat turn drops a divider after the trace steps.
let chatStarted = false;
function setChatEnabled(on) {
$("chat-text").disabled = !on;
$("chat-send").disabled = !on;
$("chat-text").placeholder = on
? "Ask the apprentice to refine the estimate…"
: "Forge an estimate, then refine it here…";
}
function appendToStream(node) {
const log = $("log");
log.appendChild(node);
log.scrollTo({ top: log.scrollHeight, behavior: "smooth" });
}
function ensureChatStarted() {
if (chatStarted) return;
chatStarted = true;
const divider = document.createElement("div");
divider.className = "chat-divider";
divider.textContent = "Refine";
$("log").appendChild(divider);
}
function appendMsg(role, text) {
ensureChatStarted();
const el = document.createElement("div");
el.className = `chat-msg ${role}`;
el.innerHTML = `<div class="bubble">${escapeHtml(text)}</div>`;
appendToStream(el);
return el;
}
function showTyping() {
ensureChatStarted();
const el = document.createElement("div");
el.className = "chat-msg bot typing";
el.innerHTML = `<div class="bubble"><i></i><i></i><i></i></div>`;
appendToStream(el);
return el;
}
let chatBusy = false;
async function sendChat(e) {
e.preventDefault();
const text = $("chat-text").value.trim();
if (!text || chatBusy || $("chat-text").disabled) return;
chatBusy = true;
$("chat-send").disabled = true;
$("chat-text").value = "";
appendMsg("user", text);
const typing = showTyping();
try {
const out = await chatAboutEstimate(text, rows, TAX_RATE, refinementThread, pendingChange);
typing.remove();
appendMsg("bot", out.reply);
if (out.thread) refinementThread = out.thread;
// Carry any pending rate change (awaiting "this estimate"/"the catalog") to next turn.
pendingChange = out.pending || null;
if (out.estimate) {
// Adopt the refined estimate; the right pane updates + total bumps.
setEstimate(out.estimate);
// If a rate changed via chat, pulse that cell so the eye lands on it.
if (out.changed) pulseRateCell(out.changed);
}
// Persist the refined estimate + thread in place (best-effort).
saveCurrent();
} catch (err) {
typing.remove();
appendMsg("bot", "Sorry — I couldn't process that just now. Try again?");
} finally {
chatBusy = false;
$("chat-send").disabled = $("chat-text").disabled;
$("chat-text").focus();
}
}
function addItem() {
rows.push({ description: "New item", quantity: 1, unit: "ea", rate: 0, subtotal: 0 });
renderRows();
recalcFromRows();
}
// --- Saved Estimates (ADR-0013): persist in place; Discard deletes the saved row. ---
// Save (create-or-update via savedId) the current estimate + thread. Best-effort:
// never block the UI on persistence.
async function saveCurrent() {
if (!rows.length) return;
try {
const out = await saveEstimate(rows, JOB_TITLE, TAX_RATE, refinementThread, savedId);
if (out && out.id) savedId = out.id;
} catch {
/* best-effort */
}
}
// --- Finalize & Send (S10): channel + recipient modal -> /api/send_estimate ---
let sendChannel = "sms";
function setSendChannel(channel) {
sendChannel = channel;
$("chan-sms").classList.toggle("is-active", channel === "sms");
$("chan-email").classList.toggle("is-active", channel === "email");
const isSms = channel === "sms";
$("send-recipient-label").textContent = isSms ? "Phone number" : "Email address";
const input = $("send-recipient");
input.placeholder = isSms ? "+1 555 123 4567" : "name@example.com";
input.inputMode = isSms ? "tel" : "email";
$("send-note").textContent = isSms
? "We'll text a link to the estimate PDF (MMS)."
: "We'll email the estimate with the PDF attached.";
}
function openSendModal() {
if (!rows.length) return; // nothing to send
$("send-form-view").hidden = false;
$("send-confirm-view").hidden = true;
$("send-confirm-view").innerHTML = "";
$("send-error").hidden = true;
$("send-recipient").value = "";
setSendChannel("sms");
$("send-overlay").hidden = false;
$("send-recipient").focus();
}
function closeSendModal() {
$("send-overlay").hidden = true;
}
async function doSend() {
const recipient = $("send-recipient").value.trim();
const err = $("send-error");
if (!recipient) {
err.textContent = "Enter a recipient first.";
err.hidden = false;
return;
}
err.hidden = true;
const goBtn = $("send-go");
goBtn.disabled = true;
goBtn.classList.add("is-busy");
try {
const out = await sendEstimate(sendChannel, recipient, rows, JOB_TITLE, TAX_RATE);
renderSendConfirm(out);
} catch (e) {
err.textContent = e.message || "Send failed.";
err.hidden = false;
} finally {
goBtn.disabled = false;
goBtn.classList.remove("is-busy");
}
}
function renderSendConfirm(out) {
const sent = out.transmitted === true;
const chanLabel = out.channel === "sms" ? "text" : "email";
const icon = sent ? "check_circle" : "drafts";
// Honest framing (ADR-0005): on the public Space nothing is transmitted, and the
// card says so plainly rather than claiming a send that didn't happen.
const headline = sent ? `Sent by ${chanLabel}` : `Draft ready`;
const sub = sent
? `Delivered to ${escapeHtml(out.recipient)}.`
: `Prepared for ${escapeHtml(out.recipient)} — real ${chanLabel} send runs on the local machine (see the demo). Nothing was transmitted from this hosted demo.`;
const ref =
sent && out.provider_id ? `<p class="send-ref">Ref: ${escapeHtml(out.provider_id)}</p>` : "";
$("send-confirm-view").innerHTML = `
<div class="send-confirm ${sent ? "is-sent" : "is-draft"}">
<span class="material-symbols-outlined send-confirm-icon">${icon}</span>
<h2>${headline}</h2>
<p class="send-summary">${escapeHtml(out.summary)}</p>
<p class="send-confirm-sub">${sub}</p>
${ref}
<button class="btn btn--primary" id="send-done" type="button">Done</button>
</div>`;
$("send-form-view").hidden = true;
$("send-confirm-view").hidden = false;
$("send-done").addEventListener("click", closeSendModal);
$("send-done").focus();
}
// --- Phone-capture pairing (Tier 3): scan a QR, capture on the phone, forge here ---
let pairPoll = null;
function stopPairing() {
if (pairPoll) {
clearInterval(pairPoll);
pairPoll = null;
}
}
async function openPhoneCapture() {
const out = await createPairing();
$("phone-qr").innerHTML =
out.qr_svg || '<p class="send-note">QR unavailable — open the link below on your phone.</p>';
const link = $("phone-url");
link.textContent = out.capture_url;
link.href = out.capture_url;
$("phone-status").textContent = "Waiting for your phone…";
$("phone-overlay").hidden = false;
// Poll the pairing; when the phone sends a capture, close + forge it live.
stopPairing();
pairPoll = setInterval(async () => {
const capture = await pollPairing(out.code);
if (!capture) return;
stopPairing();
$("phone-overlay").hidden = true;
// Adopt the phone's capture into the workspace, then forge on screen.
imagePaths = capture.image_paths || [];
$("thumbs").innerHTML = "";
$("transcript").value = capture.transcript || $("transcript").value;
forge();
}, 1500);
}
function closePhoneCapture() {
stopPairing();
$("phone-overlay").hidden = true;
}
$("phone-btn").addEventListener("click", openPhoneCapture);
$("phone-cancel").addEventListener("click", closePhoneCapture);
$("phone-overlay").addEventListener("click", (e) => {
if (e.target === $("phone-overlay")) closePhoneCapture();
});
$("forge-btn").addEventListener("click", forge);
$("new-estimate-btn").addEventListener("click", newEstimate);
$("photo-input").addEventListener("change", onPhotos);
$("doc-input").addEventListener("change", onDocument);
$("mic-btn").addEventListener("click", toggleRecording);
$("add-item-btn").addEventListener("click", addItem);
$("save-btn").addEventListener("click", saveCurrent);
$("pdf-btn").addEventListener("click", () => downloadPdf(rows, JOB_TITLE, TAX_RATE));
$("json-btn").addEventListener("click", () => downloadJson(rows, JOB_TITLE, TAX_RATE));
$("discard-btn").addEventListener("click", discardDraft);
$("finalize-btn").addEventListener("click", openSendModal);
$("chan-sms").addEventListener("click", () => setSendChannel("sms"));
$("chan-email").addEventListener("click", () => setSendChannel("email"));
$("send-go").addEventListener("click", doSend);
$("send-cancel").addEventListener("click", closeSendModal);
$("send-cancel-2").addEventListener("click", closeSendModal);
$("send-overlay").addEventListener("click", (e) => {
if (e.target === $("send-overlay")) closeSendModal();
});
$("send-recipient").addEventListener("keydown", (e) => {
if (e.key === "Enter") doSend();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("send-overlay").hidden) closeSendModal();
});
$("lang").addEventListener("change", onLanguageChange);
$("chat-form").addEventListener("submit", sendChat);
// Edits commit on blur (after the user leaves the cell).
$("est-rows").addEventListener("focusout", onCellEdit);
$("transcript").addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) forge();
});
// Populate the model-mode badge (stub / local / modal / mixed) + per-role labels.
async function showModelBadge() {
try {
const info = await modelInfo();
modelMode = info.mode || "stub";
const badge = $("model-badge");
badge.dataset.mode = info.mode;
$("model-badge-mode").textContent = info.mode.charAt(0).toUpperCase() + info.mode.slice(1);
const labels = { brain: "brain", perception: "vision", multilingual: "lang" };
$("model-badge-roles").textContent = Object.entries(info.roles)
.map(([role, name]) => `${labels[role] || role}: ${name}`)
.join(" · ");
badge.hidden = false;
} catch {
/* badge is informational; never block the app if it fails */
}
}
showModelBadge();
// Cold-start feedback: this Space scales to zero, so the first load after an idle period
// boots the container. By the time this runs the container is up (HF served the page), but
// we confirm the app is reachable and reassure the visitor it isn't broken — then fade out.
// On the local run /api/model_info answers instantly, so the banner barely flashes.
async function bootCheck() {
const banner = $("boot-banner");
if (!banner) return;
banner.hidden = false;
const ready = () => {
banner.dataset.state = "ready";
$("boot-text").textContent = "Ready.";
setTimeout(() => (banner.hidden = true), 1400);
};
for (let attempt = 0; attempt < 30; attempt++) {
try {
const res = await fetch("/api/model_info", { cache: "no-store" });
if (res.ok) return ready();
} catch {
/* container still warming — keep waiting */
}
await new Promise((r) => setTimeout(r, 1500));
}
// Gave up waiting: leave a calm, honest message rather than a spinner forever.
banner.dataset.state = "slow";
$("boot-text").textContent = "Still waking up — try reloading the page in a moment.";
}
bootCheck();
// First paint: show the empty-estimate state rather than a bare table header.
renderRows();
// Reopen flow (ADR-0013): /?estimate=<id> loads the frozen snapshot + its thread.
const reopenId = new URLSearchParams(location.search).get("estimate");
if (reopenId) {
loadEstimate(reopenId).then((rec) => {
if (!rec) return;
savedId = rec.id;
refinementThread = rec.thread || [];
setEstimate(rec.estimate, true);
// Replay the saved thread as chat bubbles (read-only history on reopen).
(rec.thread || []).forEach((t) => appendMsg("user", t.message));
});
}