phase: 159_edit_and_resend_message
All completion criteria verified. Final report:
**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end)
**Verified (implementation landed + tests green):**
- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly
- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended
**Test/lint results (all green):**
- `uv run pytest` — 3,661 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed**
- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4
**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol).
**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed.
**Notable:** none — no defects found in prior work; no live-infrastructure changes required.
**Next pending phase:** `160_tool_budget_telemetry`
This commit is contained in:
+317
-1
@@ -600,12 +600,33 @@ function appendFailedNote(wrap, detail) {
|
||||
* key off it. */
|
||||
let lastBrainWrap = null;
|
||||
|
||||
/* Phase 159 (task 01, TODO.md L3): the rendered wrap of the CURRENT
|
||||
* last USER record — the exact mirror of lastBrainWrap (one `who`
|
||||
* over). Assigned exactly where a user bubble becomes the newest
|
||||
* question (runTurn's fresh-send addMessage — the reask path keeps
|
||||
* the existing bubble, so no assignment there — and the phase-14
|
||||
* restore's user branch, where the LAST restored user bubble wins),
|
||||
* and cleared by startNewChat. Both markLastEditable's targeting and
|
||||
* openEditor's stale-click guard key off it (the phase-49
|
||||
* single-target discipline). Task 02 reuses the variable for the tail
|
||||
* truncation. */
|
||||
let lastUserWrap = null;
|
||||
|
||||
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
|
||||
* text carries the accessible name), currentColor so the CSS themes the
|
||||
* stroke (ink-soft → ink on hover, the phase-08 palette). */
|
||||
const RETRY_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
||||
|
||||
/* Phase 159 (task 01, TODO.md L3): the Edit button's pencil glyph
|
||||
* (aria-hidden decoration — the visible "Edit" label carries the
|
||||
* accessible name). Same stroke family as RETRY_ICON: fill="none",
|
||||
* currentColor stroke (the CSS themes it — ink-soft → ink on hover,
|
||||
* the phase-08 palette), 24×24 viewBox, stroke-width 1.7. No icon
|
||||
* font, no CDN (A11). */
|
||||
const EDIT_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
|
||||
|
||||
/* "Retry" button in the last brain bubble's actions row — the redo
|
||||
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
|
||||
* pattern through getActionsRow (phase 130 task 02 — the role=list
|
||||
@@ -627,6 +648,30 @@ function appendRetryButton(wrap) {
|
||||
meta.appendChild(btn);
|
||||
}
|
||||
|
||||
/* "Edit" button in the last USER bubble's actions row (phase 159,
|
||||
* task 01, TODO.md L3 — the trim-and-re-ask remedy: a "Question too
|
||||
* long — trim it and re-ask" turn, or any mistyped / half-formed
|
||||
* question, is fixed by editing, never by retyping). The house
|
||||
* pattern through getActionsRow (phase 130 task 02 — the role=list
|
||||
* row joins the button as a listitem so ARIA stays valid); one
|
||||
* button per bubble. The visible "Edit" label carries the accessible
|
||||
* name (no ellipsis clipping needed at this width). Click →
|
||||
* openEditor(wrap, btn). NOT admin-gated, unlike appendTuneButton —
|
||||
* chat is public, so every visitor who can chat can edit the last
|
||||
* question (the phase-49 Retry precedent). */
|
||||
function appendEditButton(wrap) {
|
||||
const meta = getActionsRow(wrap);
|
||||
if (!meta) return;
|
||||
if (meta.querySelector(".edit-btn")) return; // one per bubble
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "edit-btn";
|
||||
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
||||
btn.innerHTML = EDIT_ICON + "<span>Edit</span>";
|
||||
btn.addEventListener("click", () => openEditor(wrap, btn));
|
||||
meta.appendChild(btn);
|
||||
}
|
||||
|
||||
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
|
||||
* button lives on exactly ONE bubble — the last brain answer. Remove
|
||||
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
|
||||
@@ -662,6 +707,44 @@ function markLastRetryable() {
|
||||
appendRetryButton(lastBrainWrap);
|
||||
}
|
||||
|
||||
/* Phase 159 (task 01, TODO.md L3): the Edit button's single-target
|
||||
* management — the sibling of markLastRetryable (the phase-49
|
||||
* discipline, one `who` over): exactly ONE editable bubble, the
|
||||
* NEWEST question (owner-locked 2026-10-05; earlier bubbles are
|
||||
* never editable, so no mid-conversation branch exists and the
|
||||
* "what happens to the turns after it" question never arises for an
|
||||
* old message). Remove every rendered .edit-btn FIRST (an older
|
||||
* bubble's button is stale the moment a newer question lands), then
|
||||
* bail while a turn is in flight (the retryLastTurn guard — the
|
||||
* button never appears or moves mid-turn; runTurn's idle settle
|
||||
* re-runs this at rest), then scan `conversation` NEWEST-FIRST for
|
||||
* `who === "user"` through lastUserRecordIndex() (ASSUMPTION: the
|
||||
* index comes from the record scan — the markLastRetryable
|
||||
* last-brain-index rule, one `who` over — NOT from a DOM↔record
|
||||
* index map: the DOM and the record list are parallel today, but
|
||||
* re-deriving the index from the record keeps the editor correct
|
||||
* after the task-02 truncation), and bail on -1 or a missing
|
||||
* lastUserWrap — the wrap is assigned exactly where a user bubble
|
||||
* is created (runTurn's fresh send, the restore's user branch) and
|
||||
* cleared by startNewChat, so a stale wrap can never carry the
|
||||
* button. startNewChat needs no call: its list reset removes the
|
||||
* buttons along with the list (the phase-49 reasoning). Task 02
|
||||
* reuses lastUserRecordIndex() for the truncation. */
|
||||
function lastUserRecordIndex() {
|
||||
for (let i = conversation.length - 1; i >= 0; i -= 1) {
|
||||
if (conversation[i].who === "user") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function markLastEditable() {
|
||||
messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
if (lastUserRecordIndex() === -1) return; // no user record — nothing to edit
|
||||
if (!lastUserWrap) return; // no tracked bubble — a stale wrap never carries the button
|
||||
appendEditButton(lastUserWrap);
|
||||
}
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
|
||||
* "Save as doc" action of EVERY completed brain bubble (deflected
|
||||
* included — same scope as Tune; a stopped partial is a note, not an
|
||||
@@ -940,6 +1023,212 @@ function openTuneForm(wrap, toggleBtn) {
|
||||
form.querySelector("textarea").focus();
|
||||
}
|
||||
|
||||
/* Phase 159 (task 01, TODO.md L3): the INLINE editor in the bubble —
|
||||
* the openTuneForm family (owner-locked 2026-10-05: editing happens
|
||||
* IN the bubble, not the composer — the composer keeps its own draft
|
||||
* untouched by editing). A labeled textarea (rows 3, maxlength =
|
||||
* MAX_QUESTION_CHARS — the 4000 cap stays single-sourced) prefilled
|
||||
* from the record's text (through .value — never an HTML string, the
|
||||
* house no-injection rule), Save (submit) + Cancel, and a
|
||||
* role=alert .edit-error line. While open the rendered .bubble is
|
||||
* hidden (.is-editing — the question appears ONCE); Cancel / Escape
|
||||
* restore it and return focus to the toggle button (the openTuneForm
|
||||
* cancel rule); Ctrl/Cmd+Enter submits. One editor at a time.
|
||||
* Stale-click guard (the phase-49 discipline): the click's wrap must
|
||||
* still be lastUserWrap — a superseded bubble's button is inert.
|
||||
*
|
||||
* Save (task 02 — the resend): guards first, in order — the
|
||||
* in-flight no-op (a save can never start a second turn) and the
|
||||
* empty trim (the .edit-error line, the form stays open —
|
||||
* ChatRequest.message / ChatMessage.text both require min_length=1;
|
||||
* the >4000 case is unreachable: the textarea carries the
|
||||
* maxlength). Then: the trimmed text REPLACES the record in place
|
||||
* (conversation[editIdx].text); the TAIL TRUNCATION drops every
|
||||
* record the OLD question produced (conversation.splice(editIdx + 1)
|
||||
* — stale by construction, never rewritten — the phase-114
|
||||
* whole-turn rule), the matching DOM bubbles are removed (every
|
||||
* .msg after lastUserWrap), and the turn bookkeeping resets
|
||||
* (lastBrainWrap = null; leavePartialIndex cleared to -1 when it
|
||||
* pointed past editIdx — a partial saved from the dropped turn
|
||||
* must not be re-persisted into the new shape). The bubble text
|
||||
* re-renders through renderMarkdown (the addMessage renderer — the
|
||||
* markdown rules stay in one place); the attachment (if the
|
||||
* question carried one) survives the re-render (the img is
|
||||
* re-prepended — phase 123's one bubble-image position). The
|
||||
* truncated shape persists — saveConversation() (localStorage,
|
||||
* trimToBudget rides it unchanged) + persistConversation() (the
|
||||
* phase-55 auto-save — fire-and-forget, never blocks the turn; a
|
||||
* shortened messages array is already legal — no new field, no
|
||||
* migration, A10 stays stateless). markLastRetryable() re-targets:
|
||||
* no brain record exists after the drop, so the call simply clears
|
||||
* every .retry-btn (the re-ask's settle re-appends it on the fresh
|
||||
* bubble). The editor closes, the in-flight guard re-checks (the
|
||||
* handler is ONE synchronous stretch — no await between the guard
|
||||
* and the call), and runTurn(text, { reask: true }) re-asks: reask
|
||||
* skips the user append + save point 1 (the edited question is the
|
||||
* LAST record, so history = conversation.slice(0, -1) is exactly
|
||||
* the prior turns and the question is never duplicated), and NO
|
||||
* image is passed (the phase-49/123 locked A7 rule: a re-ask sends
|
||||
* text only — the record keeps its image key, the bubble keeps the
|
||||
* attachment). The promise is returned (the retryLastTurn
|
||||
* convention) — runTurn never rejects; a failure lands in the
|
||||
* phase-120 failed-turn path. The composer is never touched (the
|
||||
* unsent draft survives the edit — input.value / autoGrow /
|
||||
* updateCharCount stay untouched). The visible re-render is the
|
||||
* feedback — no new live region (the #send-status announcer stays
|
||||
* the sole a11y announcer, the phase-55 convention). */
|
||||
let editSeq = 0; // unique ids for one open edit form's inputs
|
||||
|
||||
function openEditor(wrap, toggleBtn) {
|
||||
if (wrap !== lastUserWrap) return; // stale click — the button moved on
|
||||
document.querySelectorAll(".edit-form").forEach((f) => f.remove()); // one at a time
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const editIdx = lastUserRecordIndex();
|
||||
if (editIdx === -1) return;
|
||||
editSeq += 1;
|
||||
const inputId = `edit-input-${editSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "edit-form";
|
||||
form.noValidate = true;
|
||||
const label = document.createElement("label");
|
||||
label.setAttribute("for", inputId);
|
||||
label.textContent = "Edit your question";
|
||||
const ta = document.createElement("textarea");
|
||||
ta.id = inputId;
|
||||
ta.name = "question";
|
||||
ta.setAttribute("rows", "3");
|
||||
ta.setAttribute("maxlength", String(MAX_QUESTION_CHARS));
|
||||
ta.value = conversation[editIdx].text; // prefill through .value — never an HTML string
|
||||
if (input && input.placeholder) ta.placeholder = input.placeholder; // the composer's placeholder, reused
|
||||
form.append(label, ta);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "edit-form-actions";
|
||||
const editSaveBtn = document.createElement("button");
|
||||
editSaveBtn.type = "submit";
|
||||
editSaveBtn.className = "edit-save";
|
||||
editSaveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "edit-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(editSaveBtn, cancelBtn);
|
||||
form.appendChild(actions);
|
||||
const status = document.createElement("p");
|
||||
status.className = "edit-error";
|
||||
status.setAttribute("role", "alert");
|
||||
status.hidden = true;
|
||||
form.appendChild(status);
|
||||
|
||||
const bubble = wrap.querySelector(".bubble");
|
||||
function closeEditor() {
|
||||
form.remove();
|
||||
if (bubble) bubble.classList.remove("is-editing");
|
||||
toggleBtn.focus();
|
||||
}
|
||||
ta.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeEditor(); // cancel — restore the bubble, return focus to the toggle
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit(); // Ctrl/Cmd+Enter submits
|
||||
}
|
||||
});
|
||||
cancelBtn.addEventListener("click", closeEditor);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
// In-flight re-check (the in-flight discipline): a save can never
|
||||
// start a second turn — no-op while a turn is in flight (the
|
||||
// form stays open; the user can Cancel or wait for the settle).
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
const text = ta.value.trim();
|
||||
if (!text) {
|
||||
// Empty guard: the question can't be empty (the schema's
|
||||
// min_length=1, both sides) — refused in-form, the form stays
|
||||
// open (the note is never lost, the openTuneForm failure way).
|
||||
status.textContent = "Ask something first — the question can't be empty.";
|
||||
status.hidden = false;
|
||||
return;
|
||||
}
|
||||
// Replace in place (LOCKED): the edited text IS the record from
|
||||
// this point on.
|
||||
conversation[editIdx].text = text;
|
||||
// Truncate the tail (LOCKED — the phase-49 redo-in-place rule,
|
||||
// one level up: the question is REPLACED instead of kept
|
||||
// verbatim): every record the OLD question produced is stale by
|
||||
// construction and is dropped outright (never rewritten — the
|
||||
// phase-114 whole-turn rule). The edited question is now the
|
||||
// LAST record — runTurn's history = conversation.slice(0, -1)
|
||||
// invariant holds unchanged, and the question is never
|
||||
// duplicated into the history.
|
||||
// Phase 159 (task 03): this truncation happens BEFORE the re-ask's turn starts, so the new turn's rememberBrainTurn index bookkeeping (the replaceIndex/leavePartialIndex correlation — reset to -1 when it points past editIdx, and again at runTurn's start; trimToBudget only drops HEAD-most records) sees the post-splice array and can never be confused by the drop.
|
||||
conversation.splice(editIdx + 1);
|
||||
// The matching DOM bubbles are removed: every .msg after the
|
||||
// question's bubble (#messages' direct children are the bubbles,
|
||||
// in order — the empty-state div is a leading child, so the
|
||||
// trailing siblings are exactly the dropped tail).
|
||||
let n = lastUserWrap.nextSibling;
|
||||
while (n) {
|
||||
const next = n.nextSibling;
|
||||
n.remove();
|
||||
n = next;
|
||||
}
|
||||
// Turn bookkeeping resets with the drop: the removed answer is
|
||||
// no longer a retry target, and the phase-73 pagehide partial
|
||||
// pointer must not survive the truncation — a partial saved from
|
||||
// the dropped turn must not be re-persisted into the new shape
|
||||
// (the settle's identity guard would fall back to an append).
|
||||
// -1 = none (the safe value; the next runTurn also resets it
|
||||
// turn-locally).
|
||||
lastBrainWrap = null;
|
||||
if (leavePartialIndex > editIdx) leavePartialIndex = -1;
|
||||
// The bubble text re-renders through renderMarkdown (the
|
||||
// addMessage renderer — the markdown rules stay in one place);
|
||||
// the attachment (if any) survives the re-render (re-prepended —
|
||||
// phase 123's one bubble-image position).
|
||||
if (bubble) {
|
||||
const img = bubble.querySelector(".msg-image");
|
||||
bubble.innerHTML = renderMarkdown(text);
|
||||
if (img) bubble.prepend(img);
|
||||
}
|
||||
// The truncated shape persists NOW (a crash never resurrects the
|
||||
// pre-edit text or the stale tail): saveConversation() writes
|
||||
// localStorage (trimToBudget rides it unchanged) +
|
||||
// persistConversation() auto-saves the row (the phase-55 path —
|
||||
// fire-and-forget, never blocks the turn; a shortened messages
|
||||
// array is already legal — no new field, no migration, A10
|
||||
// stays stateless).
|
||||
saveConversation();
|
||||
persistConversation();
|
||||
// The Retry button re-targets the (now removed) tail: no brain
|
||||
// record exists after the drop, so the call simply clears every
|
||||
// .retry-btn (the re-ask's settle re-appends it on the fresh
|
||||
// bubble).
|
||||
markLastRetryable();
|
||||
closeEditor();
|
||||
// In-flight re-check (the double-check — earned by the handler
|
||||
// being ONE synchronous stretch from the guard above to this
|
||||
// call: there is no await in between, so uiState cannot change;
|
||||
// the re-check keeps the invariant explicit): a save can never
|
||||
// start a second turn.
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
// Re-ask (LOCKED): reask skips the user-bubble append + the
|
||||
// persistence save point 1 (the question is already in the DOM
|
||||
// and in the record — it is never duplicated), and NO image is
|
||||
// passed (the phase-49/123 locked A7 rule: a re-ask sends text
|
||||
// only — the record keeps its image key, the bubble keeps the
|
||||
// attachment). The promise is returned (the retryLastTurn
|
||||
// convention) — runTurn never rejects; a failure lands in the
|
||||
// phase-120 failed-turn path. The composer was never touched
|
||||
// (the unsent draft survives the edit).
|
||||
return runTurn(text, { reask: true });
|
||||
});
|
||||
if (bubble) bubble.classList.add("is-editing");
|
||||
body.appendChild(form);
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||
* Inline SVG as string constants so the message renderer and the typing
|
||||
* indicator share exactly the same marks. currentColor lets the CSS theme
|
||||
@@ -2260,6 +2549,7 @@ function renderStoredMessage(m) {
|
||||
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||
if (m.who === "user") {
|
||||
const wrap = addMessage("user", renderMarkdown(m.text), true);
|
||||
lastUserWrap = wrap; // phase 159: the LAST restored user bubble wins (the mirror of the brain line below)
|
||||
// Phase 123 (task 03, TODO L6): the restored record may carry the
|
||||
// question's attached image — `m.image`, the STORED PATH (A5:
|
||||
// never base64; a pre-phase / text-only record has no key at all,
|
||||
@@ -2413,6 +2703,7 @@ function restoreConversation() {
|
||||
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
|
||||
markLastEditable(); // phase 159: the restored last user bubble is editable
|
||||
}
|
||||
|
||||
/* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ----------
|
||||
@@ -2480,6 +2771,7 @@ async function restoreSavedChatFromUrl() {
|
||||
conversation = messages; // REPLACES the local conversation (owner-locked)
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
|
||||
markLastEditable(); // phase 159: parity with the local restore: Edit on the last user bubble
|
||||
currentChatId = chatId; // linked: a subsequent Save updates THIS row
|
||||
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
||||
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
|
||||
@@ -2887,6 +3179,7 @@ function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
currentChatId = null; // phase 55: unlinked — a fresh row on its first message
|
||||
lastUserWrap = null; // phase 159: the editable wrap resets with the conversation (buttons die with the list reset below)
|
||||
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
@@ -3245,6 +3538,7 @@ function finalizeFailedTurn(
|
||||
lastBrainWrap = fwrap;
|
||||
}
|
||||
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
|
||||
markLastEditable(); // phase 159: the failed turn's question stays editable (the trim-and-re-ask remedy — the settle re-runs at idle)
|
||||
}
|
||||
|
||||
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
|
||||
@@ -3266,12 +3560,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// text-only send, every re-ask — A7: a redo re-sends the text
|
||||
// only) leaves the bubble and the record byte-identical to
|
||||
// pre-phase.
|
||||
addMessage(
|
||||
const userWrap = addMessage(
|
||||
"user",
|
||||
renderMarkdown(text),
|
||||
true, // reveal my message (owner-kept)
|
||||
image ? { src: image.src || image.path, alt: image.alt } : null
|
||||
);
|
||||
lastUserWrap = userWrap; // phase 159: the newest question bubble is the editable one
|
||||
// Persistence save point 1: the question is stored the moment it is
|
||||
// sent, so a failed/interrupted turn never loses it. The `image`
|
||||
// key (the stored path) joins the `bor.chat.v1` record only when an
|
||||
@@ -3647,6 +3942,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
appendRelated(wrap, ev.related); // phase 113 — above the actions row
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
|
||||
markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
|
||||
// Phase 59: the RAW persisted markdown (never the rendered
|
||||
// HTML) — exactly the string rememberBrainTurn stores above,
|
||||
// so a reload (the restore path) offers the identical draft.
|
||||
@@ -3732,12 +4028,24 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}, leavePartialIndex); // persist what the user actually saw
|
||||
lastBrainWrap = fwrap;
|
||||
markLastRetryable(); // phase 49: the fallback bubble is retryable too
|
||||
markLastEditable(); // phase 159: the failed turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
|
||||
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborted) {
|
||||
// The 300s guard already took the turn to the error state — its
|
||||
// own abort surfaces here, and it is never read as a user stop.
|
||||
// Phase 159 (task 03): the aborted settle must not strand the
|
||||
// question — the same mark pair as the other non-`done` settles.
|
||||
// markLastRetryable re-targets (a timed-out turn persists NO
|
||||
// brain record — the phase-120 exclusion — so the pair re-lands
|
||||
// the previous turn's target and clears stale buttons), and
|
||||
// markLastEditable lands Edit on the question NOW (the guard
|
||||
// bails only on thinking/streaming — the error state passes;
|
||||
// the finally idle-settle re-runs it — the remove-then-append
|
||||
// keeps the double pass harmless).
|
||||
markLastRetryable();
|
||||
markLastEditable();
|
||||
} else if (stoppedByUser || err?.name === "AbortError") {
|
||||
// Phase 48 (owner-locked): the STOP path — no error banner. When
|
||||
// answer text streamed, keep the partial on screen and persist it
|
||||
@@ -3767,6 +4075,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}, leavePartialIndex);
|
||||
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
|
||||
markLastRetryable(); // phase 49: Retry on the stopped partial
|
||||
markLastEditable(); // phase 159: the stopped turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
|
||||
}
|
||||
// Pre-token / thinking-only stop: persist NOTHING brain-side
|
||||
// (phase-20 convention — the question is already saved on send).
|
||||
@@ -3817,6 +4126,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
cancelStream(res); // the reader lock is released — no unhandled rejection
|
||||
turnAbort = null; // phase 48: the turn's abort owner is spent
|
||||
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
||||
// Phase 159 (task 01): the terminal settle (idle) is the ONE mark
|
||||
// pass that lands the Edit button at rest — the mid-turn paired
|
||||
// call sites (done / zero-frame / stop / failed funnel) no-op on
|
||||
// the in-flight guard, so this re-run at idle is what actually
|
||||
// appends (a New-chat-mid-turn clears the records + the wrap, so
|
||||
// this is a no-op there by construction).
|
||||
markLastEditable();
|
||||
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
|
||||
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
|
||||
// coarse pointer (a phone/tablet) focusing the composer pops the
|
||||
|
||||
Reference in New Issue
Block a user