fix(chat): warn before regenerate, resend or edit deletes later messages; offer fork (#6225)

* fix(chat): warn before "regenerate" permanently deletes later messages

Both "regenerate" entry points — the per-message footer's ↻ "Regenerate
from here" button, and the vision editor's "Regenerate message" button —
call POST /api/session/{id}/truncate, which does a real, permanent SQL
delete of every message after the point clicked, then generates a new
reply. Clicking either on anything but the very last exchange did this with
zero warning: the button's own label doesn't say it also erases the rest
of the conversation, and neither button visually signals it's destructive
differently from any other message-footer action.

Caught live: a mis-click destroyed a real conversation with no way to
recover it (no backup covered the affected window). This adds a
confirmation prompt — via the existing uiModule.styledConfirm — whenever
the action would delete more than just the message(s) being regenerated,
stating the exact count of messages that would be lost. Regenerating the
most recent exchange (the everyday case, nothing exists after it) is
unaffected and still proceeds with zero friction.

New test: tests/test_regenerate_truncate_warning_js.py — drives the real
regenerateFrom/resendUserMessage functions under Node with a fake message
list DOM, confirming: the last-exchange case never prompts and truncates
directly; an earlier case prompts with the correct count; cancelling
aborts before any network call; confirming proceeds with the correct
keep_count. Also fixed a pre-existing, unrelated gap this surfaced while
verifying the full suite: test_pr6020_browser_review_regressions.py's
Node harness never stubbed `navigator` at all (sessions.js reads
navigator.platform at module load) — a gap on origin/dev itself, not
caused by this branch's diff, using the same Object.defineProperty fix
already applied on #6019/#6148 for the sibling issue there (Node 21+'s own
getter-only navigator global, not relevant to this specific failure but
the same defensive pattern).

Verified: full pytest suite in-container, 5798 passed / 11 pre-existing
unrelated failures (README/docs/agent-loop baseline) / 0 new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(chat): cover plain resend after dev's replace-by-default change

dev made plain resend (no options) replace from the clicked turn by
default, with append-only resend opt-in via { append: true } (see
test_resend_message_nondestructive.py). The footer "Resend message"
button calls resendUserMessage(el) with no options, so it now truncates
everything after the clicked turn and goes through this PR's warning.
Replace the old "plain resend never prompts" test, which assumed the
previous append-by-default behavior, with one asserting that plain
resend on an earlier pair prompts with the right count, and one
asserting that explicit append-only resend never truncates or prompts.

Also resolve ui.js/sessions.js through the same cache-busting query
string chat.js imports them with. ES modules cache by full URL, so the
harness's unversioned import of ui.js was a second module instance and
its uiMod.styledConfirm stub never reached the instance chat.js calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): warn before an edit deletes later messages; offer to fork instead

Sending an edited user message truncates the chat at that message, the
same unrecoverable server-side delete as regenerate and resend, but did
it with no prompt. It now goes through the same confirmation, worded for
the action ("Sending this edit will also permanently delete the N
messages after it"). Cancelling leaves the editor open with the draft.

The confirmation for all three actions (regenerate, resend, edit) also
offers "Fork from here": it forks the chat just before the user turn,
switches to the new chat, and replays the action there (the same message,
or the edited text), leaving the original chat untouched. forkFrom now
shares the same fork helper.

Tests drive the real editUserMessage Send handler under Node and cover
the edit prompt (count, cancel, last-message no-prompt) and the fork
choice for each action (fork keep_count, no truncate, replayed text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): cut and fork a chat by message id, not loaded-page position

Regenerate and edit sent keep_count, the clicked message's position among
the messages loaded in the page. A long chat loads only its newest messages
until the user scrolls up, so that position is smaller than the real one and
the truncate deleted real messages above the clicked one (regenerating the
latest reply in a ~50-message chat removed about a dozen earlier messages).
Resend already sent before_msg_id; regenerate and edit now do the same.

The fork options this PR adds had the same flaw. Fork now accepts
before_msg_id (fork before that message) and through_msg_id (include it),
resolved against the stored messages; keep_count stays as the fallback for a
message that has no id yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): keep a regenerated turn's message id current; stop on a refused cut

Regenerate keeps the original user bubble on screen and resubmits its text
without drawing a new bubble. The resubmitted message is a new DB row, but
the bubble kept the deleted row's id, so the next regenerate's cut was
refused (404) and the page sent the turn anyway, stacking a second reply.

The stream now reports the saved user message's id (user_message_saved),
which the page attaches to the user bubble, or to the bubble a regenerate
kept. Regenerate and edit stop with an error when the server refuses the
cut instead of sending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): name the buttons in words instead of symbols

CONTRIBUTING asks for no Unicode emoji in UI or code; the test comments
used the regenerate and pencil glyphs to name buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chat): share the fork-and-send step across edit, resend and regenerate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): pass the regenerate fork its attachment ids explicitly

Resend already hands its collected file ids to _forkAndSend; regenerate
relied on the earlier _pendingRegenAttachments assignment. Pass _regenIds
too so both fork paths read the same, and check the hand-off in the
regenerate tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): assert fork choices keep the user message's attachments

Run regenerate and resend's "Fork from here" against a user message with
an uploaded file and check that the send fired in the fork carries its
file id, replacing the source-text check. chat.js exposes a read-only
_pendingRegenAttachmentIds() for this, like cookbook-hwfit.js's test hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): preserve attachments across edit and regeneration

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
This commit is contained in:
John W
2026-11-01 02:47:32 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 Alexandre Teixeira
parent 285d05e72e
commit 5353241743
5 changed files with 769 additions and 50 deletions
+159 -50
View File
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
let _sendInFlight = false; // covers the window from click → streaming start
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
// The user bubble a regenerate keeps on screen. Its resubmitted message is
// a new DB row, so the saved id is attached here.
let _regenKeptUserBubble = null;
let _contextHeaderSeq = 0;
let _contextHeaderData = null;
let _contextHeaderBound = false;
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
refreshChatContextHeader('metrics');
}
} else if (json.type === 'user_message_saved') {
// The user bubble gets its DB id too, so regenerating or
// editing it later cuts the chat by id, not page position.
if (!_isBg && json.id) {
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
_regenKeptUserBubble = null;
}
} else if (json.type === 'message_saved') {
// Wire the persisted DB id onto the just-streamed bubble so it
// can be edited/deleted immediately, without reloading the chat.
@@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js';
/**
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
*/
// Recover attached file IDs from rendered user bubbles, including older
// image previews that were rendered before data-file-id was stamped.
function _userMessageAttachmentIds(msgElement) {
if (!msgElement) return [];
const els = msgElement.querySelectorAll('[data-file-id]');
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
if (!ids.length) {
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const image of images) {
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
}
}
return ids;
}
export async function editUserMessage(userMsgElement) {
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
e.stopPropagation();
const newText = editor.value.trim();
if (!newText) return;
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
// Everything after the original reply to this message would also be
// destroyed. Cancelling leaves the editor open with the draft intact.
const extraCount = allMsgs.length - msgIndex - 2;
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
if (!choice) return;
if (choice === 'fork') {
// Branch just before this user turn and send the edit there; this
// chat keeps the original message and everything after it.
try {
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
bodyEl.innerHTML = originalHTML;
} catch (err) {
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
}
return;
}
const keepCount = msgIndex;
// Cut at the message's database id: msgIndex counts only the messages
// loaded in the page, so it is too small when older history has not
// been scrolled in yet.
const editBeforeId = userMsgElement.dataset.dbId || '';
try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
});
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
// Remove DOM elements from msgIndex onward
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
allMsgs[i].remove();
}
// The edited user turn still refers to its original attachments.
_pendingRegenAttachments = editAttachmentIds;
// Submit the edited text
const messageInput = uiModule.el('message');
messageInput.value = newText;
@@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js';
});
}
// Regenerate, resend and edit permanently delete (server-side truncate)
// everything after the point clicked. Ask first unless nothing comes after
// it (extraCount 0), offering a fork that leaves this chat untouched.
// Resolves to 'proceed', 'fork', or null (cancelled).
const _TRUNCATE_ACTIONS = {
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
};
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
if (extraCount <= 0) return 'proceed';
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
const result = await uiModule.styledConfirm(
`${lead} will also permanently delete the ${extraCount} ` +
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
`Fork from here keeps this chat as it is and continues in a new one instead.`,
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
);
if (result === 'alternate') return 'fork';
return result ? 'proceed' : null;
}
// Copy the first `keepCount` messages of the current chat into a new chat
// and switch to it. Returns the new session id; throws on failure so callers
// that replay an action in the fork can stop before sending anything.
// Fork by message id when the element has one (before it, or through it);
// keepCount is only a fallback, since it counts loaded messages only.
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) throw new Error('No active chat');
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount,
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
return data.id;
}
// Fork before a user message and send `text` there; this chat is untouched.
// `attachments` (if given) are re-sent only once the fork has succeeded.
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
await _forkSessionAt(keepCount, beforeMsgId);
if (attachments !== undefined) _pendingRegenAttachments = attachments;
uiModule.el('message').value = text;
document.querySelector('.send-btn')?.click();
}
/**
* Resend a user message. By default this replaces from the selected user turn
* so failed attempts do not remain in model context or SFT traces. Callers can
@@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js';
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Collect file_ids attached to this user message so the resend re-carries
// the photos / docs (and the chat handler picks up the user-edited OCR
// text cached server-side under those file ids).
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
if (!_ids.length) {
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
}
}
const _ids = _userMessageAttachmentIds(userMsgElement);
// Rescue: legacy bubbles may have stored the filename as the message
// content (artifact of earlier broken resends). Don't re-send that as
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
try {
if (replaceFromHere) {
// Everything strictly after the immediate AI reply to this user
// message would also be destroyed — that's the part a click on
// Resend / "Regenerate message" doesn't expect to lose.
const extraCount = allMsgs.length - msgIndex - 2;
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
if (!choice) return;
if (choice === 'fork') {
// Branch just before this user turn and send it again there; this
// chat keeps every message.
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
return;
}
// Resend/regenerate trims history to this point before resubmitting so
// the replacement request is the only copy the backend sees.
const keepCount = msgIndex;
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
return;
}
// Collect any file_ids attached to the original user message so the
// regenerated send re-uses them. Without this the AI is regenerated on
// text alone — photos (and the user-edited OCR text cached server-side
// under that file_id) would be silently dropped.
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
// Fallback for bubbles rendered before the data-file-id stamp landed:
// sniff the file id straight out of any `.attach-image-preview img`
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
// regen with zero attachments and the photo would be lost from the
// resulting message even though the file still exists on disk.
if (!_regenIds.length && userMsgEl) {
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
}
}
_pendingRegenAttachments = _regenIds;
// Everything strictly after the AI reply being regenerated would also
// be destroyed — that's the part a click on "Regenerate from here"
// doesn't expect to lose.
const extraCount = allMsgs.length - aiIndex - 1;
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
if (!choice) return;
const _regenIds = _userMessageAttachmentIds(userMsgEl);
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
// photo's filename as the user-message content. On a follow-up regen,
@@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js';
// an image file". If userText is just a bare image filename and we have
// attachments, drop it so the OCR text (or the image bytes for vision
// models) is what the model actually sees.
if (userText && _pendingRegenAttachments.length &&
if (userText && _regenIds.length &&
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
userText = '';
}
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
// A photo-only message has empty user text — regen must still proceed,
// because the attachments themselves are the message. Bail only if there
// is no text AND no attachments to send.
if (!userText && !_pendingRegenAttachments.length) {
if (!userText && !_regenIds.length) {
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
return;
}
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
if (choice === 'fork') {
// Branch just before the user turn and send it again there, so the new
// chat gets a fresh reply to the same message; this chat keeps every
// message. No variants or hidden bubble: the fork starts without them.
try {
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
} catch (err) {
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
}
return;
}
// Save current response as a variant
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
}
const keepCount = userIndex;
// Same as resend: cut at the user message's database id, because
// userIndex counts only the messages loaded in the page.
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
});
// A refused cut (e.g. a stale id) must not stack a second reply.
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
_pendingRegenAttachments = _regenIds;
// Keep the original user bubble, but remove every rendered trace after
// it, including agent-thread tool history between the user and AI bubble.
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
_pendingVariantLabel = 'regen';
_hideUserBubble = true;
_regenKeptUserBubble = userMsgEl;
const messageInput = uiModule.el('message');
messageInput.value = userText;
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click();
} catch (err) {
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
console.error('Regenerate failed:', err);
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
}
@@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js';
// File-ids carried over from the original user message during a regen, so
// photos / OCR overrides survive into the new send. Consumed once.
let _pendingRegenAttachments = null;
// Read-only view for tests.
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
/**
* Called after streaming completes to attach variant navigation if this was a regen.
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
const keepCount = aiIndex + 1;
try {
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
} catch (err) {
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);