phase: 159_edit_and_resend_message
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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:
2026-10-05 19:07:33 -04:00
parent 4d7d0f6297
commit cb8767512a
36 changed files with 4009 additions and 22 deletions
+317 -1
View File
@@ -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
+110
View File
@@ -1027,6 +1027,37 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 159 (task 01, TODO L3): the "Edit" button — the
* trim-and-re-ask action in the .msg-meta.msg-actions row (phase 130)
* of the NEWEST user bubble (markLastEditable — the phase-49
* single-target discipline, one `who` over). The exact visual family
* of .retry-btn (same pill size/spacing, the global :focus-visible
* ring, >=44px via min-height) so the meta actions read as a set —
* but neutral: ink-soft -> ink on hover (Tune keeps the brand pair).
* The pencil glyph rides currentColor. No per-button auto margin
* (phase 130 — the row's justify right-aligns the cluster). Contrast:
* ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
* ~15.7:1 — all AA. */
.edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.edit-btn svg { width: 14px; height: 14px; display: block; }
.edit-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
button — the bottom-right action of every completed brain bubble's
meta row (the JS gates: admin + a configured docs repo; this rule
@@ -1193,6 +1224,84 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
font-weight: 600;
}
/* Phase 159 (task 01, TODO L3): the inline EDIT form in the newest
* user bubble — the .tune-form card family (labeled textarea +
* Save/Cancel, both ≥44px; the textarea's maxlength = the 4000 cap,
* single-sourced from MAX_QUESTION_CHARS in app.js). While open the
* rendered .bubble is hidden (.is-editing) so the question appears
* ONCE; Cancel / Escape restore it and return focus to the toggle.
* Save rejection (role=alert) keeps the form open (the note is never
* lost — the openTuneForm failure way). */
.msg.user .bubble.is-editing { display: none; }
.edit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
}
.edit-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-soft);
}
.edit-form textarea {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.edit-form-actions { display: flex; gap: 0.5rem; }
.edit-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.edit-save:hover:not(:disabled) { background: var(--brand-hover); }
.edit-save:disabled { opacity: 0.6; cursor: wait; }
.edit-cancel {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.edit-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Inline save rejection (role=alert): err pair ≈9.1:1 — form is kept. */
.edit-error {
margin: 0 0 0 0.25rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Header panel above the messages: notes newest-first, per-note delete,
designed empty state. */
.steering-panel {
@@ -5707,6 +5816,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
.retry-btn { min-height: 44px; }
.edit-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
the note text ellipsizes (min-width: 0). */