feat(chat): implement edit message functionality#1136
feat(chat): implement edit message functionality#1136fbricon wants to merge 1 commit intokortex-hub:mainfrom
Conversation
📝 WalkthroughWalkthroughAdds message-editing: IPC to remove trailing generated messages, a context-based EditState for editing lifecycle, UI changes to enter/cancel/edit messages (with grayed messages after the edited one), and tests + Playwright helpers covering edit/cancel and edit+regenerate flows. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant UI as Chat UI
participant EditState
participant ChatClient
participant IPC as Backend IPC
User->>UI: Click "Edit message"
UI->>EditState: startEditing(message)
EditState-->>UI: editingMessage set, isEditing true
UI->>UI: Populate textarea, gray messages after edited message
alt Submit edited message
User->>UI: Click send
UI->>IPC: inference:deleteTrailingMessages(messageId)
IPC-->>IPC: delete trailing messages in DB
UI->>ChatClient: replace edited message text
UI->>ChatClient: regenerate()
ChatClient->>IPC: request regenerate
UI->>EditState: cancelEditing()
else Cancel editing
User->>UI: Press ESC
UI->>EditState: cancelEditing()
UI->>UI: Restore textarea to saved input
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/renderer/src/lib/chat/components/multimodal-input.svelte`:
- Around line 88-108: The block handling editState.editingMessage clears input
and cancels editing before calling await
window.inferenceDeleteTrailingMessages(editingMessage.id), which can fail and
leave the UI inconsistent; wrap the inferenceDeleteTrailingMessages call and the
subsequent update/regenerate logic in a try/catch, and on catch restore
editState.editingMessage (or call editState.startEditing with the original
editingMessage), restore the input via setInput(savedInput) and savedInput
variable, and surface the error (e.g., rethrow or log) so the user can recover
and the UI remains consistent; ensure chatClient.messages update and
chatClient.regenerate only run on success.
In `@tests/playwright/src/model/pages/chat-page.ts`:
- Around line 321-339: The helper verifyMessagesGrayedAfterIndex currently
matches the edited message by innerHTML which can incorrectly match duplicates;
change the lookup of editedGlobalIndex to compare DOM node identity between
editedMessage and items in allMessages (e.g., use a node identity check via
elementHandle.evaluate(...) with node.isSameNode or Playwright's node
comparison) instead of comparing innerHTML, and if no match is found throw a
clear error rather than leaving editedGlobalIndex as -1 so the trailing-opacity
assertions don't run from index 0; update references to allMessages,
userMessages, editedMessage, and editedGlobalIndex accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 6d136b0c-14b3-42cd-b407-6d517d476956
📒 Files selected for processing (9)
packages/main/src/chat/chat-manager.tspackages/preload/src/index.tspackages/renderer/src/lib/chat/components/chat.sveltepackages/renderer/src/lib/chat/components/messages.sveltepackages/renderer/src/lib/chat/components/messages/preview-message.sveltepackages/renderer/src/lib/chat/components/multimodal-input.sveltepackages/renderer/src/lib/chat/hooks/edit-state.svelte.tstests/playwright/src/model/pages/chat-page.tstests/playwright/src/specs/provider-specs/chat-smoke.spec.ts
| if (editState.editingMessage) { | ||
| const editingMessage = editState.editingMessage; | ||
| setInput(''); | ||
| savedInput = ''; | ||
| editState.cancelEditing(); | ||
|
|
||
| await window.inferenceDeleteTrailingMessages(editingMessage.id); | ||
|
|
||
| const index = chatClient.messages.findIndex(m => m.id === editingMessage.id); | ||
| if (index !== -1) { | ||
| const updatedMessage = { | ||
| ...editingMessage, | ||
| parts: [{ type: 'text' as const, text }], | ||
| }; | ||
| chatClient.messages = [...chatClient.messages.slice(0, index), updatedMessage]; | ||
| } | ||
|
|
||
| resetHeight(); | ||
| await chatClient.regenerate(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Missing error handling could leave the UI in an inconsistent state.
If inferenceDeleteTrailingMessages fails (e.g., database error, message already deleted), the editing state is already cancelled and the input cleared (lines 90-92), leaving the user with no recovery path. Consider wrapping this in try/catch to restore the editing state on failure.
🛡️ Proposed fix to add error handling
if (editState.editingMessage) {
const editingMessage = editState.editingMessage;
+ const originalInput = input;
setInput('');
savedInput = '';
editState.cancelEditing();
- await window.inferenceDeleteTrailingMessages(editingMessage.id);
+ try {
+ await window.inferenceDeleteTrailingMessages(editingMessage.id);
+ } catch (error) {
+ // Restore editing state on failure
+ editState.startEditing(editingMessage);
+ setInput(originalInput);
+ toast.error('Failed to update message. Please try again.');
+ return;
+ }
const index = chatClient.messages.findIndex(m => m.id === editingMessage.id);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/renderer/src/lib/chat/components/multimodal-input.svelte` around
lines 88 - 108, The block handling editState.editingMessage clears input and
cancels editing before calling await
window.inferenceDeleteTrailingMessages(editingMessage.id), which can fail and
leave the UI inconsistent; wrap the inferenceDeleteTrailingMessages call and the
subsequent update/regenerate logic in a try/catch, and on catch restore
editState.editingMessage (or call editState.startEditing with the original
editingMessage), restore the input via setInput(savedInput) and savedInput
variable, and surface the error (e.g., rethrow or log) so the user can recover
and the UI remains consistent; ensure chatClient.messages update and
chatClient.regenerate only run on success.
| async verifyMessagesGrayedAfterIndex(userMessageIndex: number): Promise<void> { | ||
| const allMessages = await this.conversationMessages.all(); | ||
| const userMessages = await this.userConversationMessages.all(); | ||
| const editedMessage = userMessages[userMessageIndex]; | ||
|
|
||
| // Find the position of the edited user message in all messages | ||
| let editedGlobalIndex = -1; | ||
| for (let i = 0; i < allMessages.length; i++) { | ||
| const el = allMessages[i]; | ||
| if ((await el.innerHTML()) === (await editedMessage.innerHTML())) { | ||
| editedGlobalIndex = i; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Messages after the edited one should have reduced opacity | ||
| for (let i = editedGlobalIndex + 1; i < allMessages.length; i++) { | ||
| await expect(allMessages[i]).toHaveClass(/opacity-40/); | ||
| } |
There was a problem hiding this comment.
Match the edited message by DOM order, not innerHTML().
If two user messages render the same markup, Line 330 will match the first duplicate, so this helper can assert the wrong trailing messages. If nothing matches, editedGlobalIndex stays -1 and the loop starts from 0, which makes the failure misleading.
🛠️ Proposed fix
async verifyMessagesGrayedAfterIndex(userMessageIndex: number): Promise<void> {
const allMessages = await this.conversationMessages.all();
- const userMessages = await this.userConversationMessages.all();
- const editedMessage = userMessages[userMessageIndex];
-
- // Find the position of the edited user message in all messages
- let editedGlobalIndex = -1;
- for (let i = 0; i < allMessages.length; i++) {
- const el = allMessages[i];
- if ((await el.innerHTML()) === (await editedMessage.innerHTML())) {
- editedGlobalIndex = i;
- break;
- }
- }
+ const editedGlobalIndex = await this.conversationMessages.evaluateAll((nodes, targetUserIndex) => {
+ let currentUserIndex = -1;
+
+ return nodes.findIndex(node => {
+ if ((node as HTMLElement).dataset.role !== 'user') {
+ return false;
+ }
+
+ currentUserIndex += 1;
+ return currentUserIndex === targetUserIndex;
+ });
+ }, userMessageIndex);
+ expect(editedGlobalIndex).toBeGreaterThanOrEqual(0);
// Messages after the edited one should have reduced opacity
for (let i = editedGlobalIndex + 1; i < allMessages.length; i++) {
await expect(allMessages[i]).toHaveClass(/opacity-40/);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async verifyMessagesGrayedAfterIndex(userMessageIndex: number): Promise<void> { | |
| const allMessages = await this.conversationMessages.all(); | |
| const userMessages = await this.userConversationMessages.all(); | |
| const editedMessage = userMessages[userMessageIndex]; | |
| // Find the position of the edited user message in all messages | |
| let editedGlobalIndex = -1; | |
| for (let i = 0; i < allMessages.length; i++) { | |
| const el = allMessages[i]; | |
| if ((await el.innerHTML()) === (await editedMessage.innerHTML())) { | |
| editedGlobalIndex = i; | |
| break; | |
| } | |
| } | |
| // Messages after the edited one should have reduced opacity | |
| for (let i = editedGlobalIndex + 1; i < allMessages.length; i++) { | |
| await expect(allMessages[i]).toHaveClass(/opacity-40/); | |
| } | |
| async verifyMessagesGrayedAfterIndex(userMessageIndex: number): Promise<void> { | |
| const allMessages = await this.conversationMessages.all(); | |
| const editedGlobalIndex = await this.conversationMessages.evaluateAll((nodes, targetUserIndex) => { | |
| let currentUserIndex = -1; | |
| return nodes.findIndex(node => { | |
| if ((node as HTMLElement).dataset.role !== 'user') { | |
| return false; | |
| } | |
| currentUserIndex += 1; | |
| return currentUserIndex === targetUserIndex; | |
| }); | |
| }, userMessageIndex); | |
| expect(editedGlobalIndex).toBeGreaterThanOrEqual(0); | |
| // Messages after the edited one should have reduced opacity | |
| for (let i = editedGlobalIndex + 1; i < allMessages.length; i++) { | |
| await expect(allMessages[i]).toHaveClass(/opacity-40/); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/playwright/src/model/pages/chat-page.ts` around lines 321 - 339, The
helper verifyMessagesGrayedAfterIndex currently matches the edited message by
innerHTML which can incorrectly match duplicates; change the lookup of
editedGlobalIndex to compare DOM node identity between editedMessage and items
in allMessages (e.g., use a node identity check via elementHandle.evaluate(...)
with node.isSameNode or Playwright's node comparison) instead of comparing
innerHTML, and if no match is found throw a clear error rather than leaving
editedGlobalIndex as -1 so the trailing-opacity assertions don't run from index
0; update references to allMessages, userMessages, editedMessage, and
editedGlobalIndex accordingly.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
I've restarted the failing job |
Clicking the edit button on a user message populates the input box with the message text, shows a "Press ESC to cancel editing" hint, and grays out all messages after the edited one. Submitting the edit deletes trailing messages from the database and regenerates the AI response. - Add EditState context for shared editing state across components - Add deleteTrailingMessages IPC endpoint and preload exposure - Update multimodal-input to handle edit mode (ESC cancel, submit edit) - Update preview-message to gray out messages and hide edit buttons during editing - Add e2e tests for edit cancel (CHAT-12) and edit submit (CHAT-13) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Fred Bricon <fbricon@gmail.com>
c48a709 to
a2c6c4e
Compare
|
clicked on the rebase button as the other pr was merged ( commit included in this pr) |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tests/playwright/src/model/pages/chat-page.ts (1)
321-339:⚠️ Potential issue | 🟡 MinorUse deterministic message indexing instead of
innerHTML()matching.At Line 330, matching by
innerHTML()can misidentify the edited message when content repeats. If no match is found,editedGlobalIndexremains-1, and Line 337 starts assertions from index0, producing misleading failures.🛠️ Suggested fix
async verifyMessagesGrayedAfterIndex(userMessageIndex: number): Promise<void> { const allMessages = await this.conversationMessages.all(); - const userMessages = await this.userConversationMessages.all(); - const editedMessage = userMessages[userMessageIndex]; - - // Find the position of the edited user message in all messages - let editedGlobalIndex = -1; - for (let i = 0; i < allMessages.length; i++) { - const el = allMessages[i]; - if ((await el.innerHTML()) === (await editedMessage.innerHTML())) { - editedGlobalIndex = i; - break; - } - } + const editedGlobalIndex = await this.conversationMessages.evaluateAll((nodes, targetUserIndex) => { + let currentUserIndex = -1; + return nodes.findIndex(node => { + if ((node as HTMLElement).dataset.role !== 'user') return false; + currentUserIndex += 1; + return currentUserIndex === targetUserIndex; + }); + }, userMessageIndex); + expect(editedGlobalIndex).toBeGreaterThanOrEqual(0); // Messages after the edited one should have reduced opacity for (let i = editedGlobalIndex + 1; i < allMessages.length; i++) { await expect(allMessages[i]).toHaveClass(/opacity-40/); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/playwright/src/model/pages/chat-page.ts` around lines 321 - 339, The test uses innerHTML to match elements which can misidentify duplicates and leave editedGlobalIndex at -1; modify verifyMessagesGrayedAfterIndex to locate the edited message deterministically by comparing node identity between arrays (use ElementHandle.evaluate with node.isSameNode or similar) to set editedGlobalIndex, add a guard that throws/fails if editedGlobalIndex remains -1, then proceed with the opacity assertions on conversationMessages; reference function verifyMessagesGrayedAfterIndex, variables conversationMessages, userConversationMessages, and editedGlobalIndex.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@tests/playwright/src/model/pages/chat-page.ts`:
- Around line 321-339: The test uses innerHTML to match elements which can
misidentify duplicates and leave editedGlobalIndex at -1; modify
verifyMessagesGrayedAfterIndex to locate the edited message deterministically by
comparing node identity between arrays (use ElementHandle.evaluate with
node.isSameNode or similar) to set editedGlobalIndex, add a guard that
throws/fails if editedGlobalIndex remains -1, then proceed with the opacity
assertions on conversationMessages; reference function
verifyMessagesGrayedAfterIndex, variables conversationMessages,
userConversationMessages, and editedGlobalIndex.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 08a57fe6-fc71-4c44-8a00-21a7b1f272ff
📒 Files selected for processing (9)
packages/main/src/chat/chat-manager.tspackages/preload/src/index.tspackages/renderer/src/lib/chat/components/chat.sveltepackages/renderer/src/lib/chat/components/messages.sveltepackages/renderer/src/lib/chat/components/messages/preview-message.sveltepackages/renderer/src/lib/chat/components/multimodal-input.sveltepackages/renderer/src/lib/chat/hooks/edit-state.svelte.tstests/playwright/src/model/pages/chat-page.tstests/playwright/src/specs/provider-specs/chat-smoke.spec.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/renderer/src/lib/chat/components/chat.svelte
- tests/playwright/src/specs/provider-specs/chat-smoke.spec.ts
- packages/renderer/src/lib/chat/hooks/edit-state.svelte.ts
- packages/renderer/src/lib/chat/components/multimodal-input.svelte
- packages/main/src/chat/chat-manager.ts
This PR provides support for editing chat messages. In the original Vercel Chatbot UI, the message was edited in place (Claude Desktop does it too), but I prefer Ollama client's approach, where editing happens in a single place, the input box at the bottom, providing a consistent UI (includes all existing and future buttons).
Clicking the edit button on a user message populates the input box with the message text, shows a "Press ESC to cancel editing" hint, and grays out all messages after the edited one. Submitting the edit deletes trailing messages from the database and regenerates the AI response. Just like in Ollama client.
during editing
Includes the fix from #1134
See it in action:
edit-message.mp4
Admittedly, there's one thing Claude Desktop does better, which is, instead of deleting trailing messages, it just forks the conversation and allows the user to switch between different branches. But it's a more complex approach that'll certainly require database schema changes. Not impossible to do but I think the current approach is a good starting point. We can certainly implement conversation forking in a subsequent PR, if needed. cc @slemeur
Fixes #1081