feat(chat): add message editing & regeneration (M8.1)

Pencil icon on hover for user messages to edit inline. Regenerate
icon on assistant messages to re-send from the same prompt. Editing
clears all subsequent messages and triggers a fresh AI response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:01:48 +00:00
co-authored by Claude Opus 4.6
parent 9f6158ddd2
commit 61a6e88667
5 changed files with 251 additions and 21 deletions
+105 -20
View File
@@ -1,25 +1,73 @@
<template>
<div
class="flex animate-fade-up-fast"
class="group/msg flex animate-fade-up-fast"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
>
<div
class="max-w-[85%] md:max-w-[75%] rounded-2xl px-4 py-3 transition-all duration-300"
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<div class="relative max-w-[85%] md:max-w-[75%]">
<!-- Action buttons (hover on desktop, always visible on touch) -->
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'"
v-html="renderedMarkdown"
/>
<p
v-else
class="text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'"
>{{ displayText }}</p>
v-if="!isEditing"
class="absolute -top-3 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10 flex gap-1"
:class="isUser ? 'right-1' : 'left-1'"
>
<button
v-if="isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
</div>
<div
class="rounded-2xl px-4 py-3 transition-all duration-300"
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<!-- Edit mode -->
<div v-if="isEditing" class="space-y-2" @click.stop>
<textarea
ref="editTextareaRef"
v-model="editContent"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="3"
@keydown.enter.exact="submitEdit"
@keydown.escape="cancelEdit"
/>
<div class="flex gap-2 justify-end">
<button
class="glass-button-sm !h-7 !min-h-0 text-xs text-white/60 hover:text-white/90 px-3"
@click="cancelEdit"
>Cancel</button>
<button
class="glass-button-sm !h-7 !min-h-0 text-xs bg-accent/20 text-accent hover:bg-accent/30 px-3"
@click="submitEdit"
>Save & Resend</button>
</div>
</div>
<!-- Normal display -->
<template v-else>
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="renderedMarkdown"
/>
<p
v-else
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
>{{ displayText }}</p>
</template>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
@@ -109,9 +157,9 @@
/>
</div>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] select-none"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
<div v-if="!isEditing" class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] select-none text-white/30">{{ formattedTime }}</span>
<span v-if="message.editedAt" class="text-[10px] text-white/25 select-none">(edited)</span>
<button
v-if="inlineFilms.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@@ -183,12 +231,13 @@
View brief
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
@@ -215,7 +264,43 @@ const props = withDefaults(
{ triggeringQuery: '' }
)
const emit = defineEmits<{
edit: [messageId: string, newContent: string]
regenerate: []
}>()
const { isDark } = useTheme()
// Editing state
const isEditing = ref(false)
const editContent = ref('')
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
function startEditing() {
editContent.value = props.message.content
isEditing.value = true
nextTick(() => {
editTextareaRef.value?.focus()
// Auto-size textarea
if (editTextareaRef.value) {
editTextareaRef.value.style.height = 'auto'
editTextareaRef.value.style.height = editTextareaRef.value.scrollHeight + 'px'
}
})
}
function cancelEdit() {
isEditing.value = false
editContent.value = ''
}
function submitEdit() {
const trimmed = editContent.value.trim()
if (!trimmed) return
emit('edit', props.message.id, trimmed)
isEditing.value = false
editContent.value = ''
}
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
@@ -57,6 +57,8 @@
:message="messages[virtualRow.index]"
:index="virtualRow.index"
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
@edit="handleEdit"
@regenerate="handleRegenerate"
/>
</ErrorBoundary>
</div>
@@ -112,7 +114,7 @@ defineEmits<{
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration } = useAI()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse } = useAI()
const { isDark } = useTheme()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
@@ -163,6 +165,14 @@ function handleStop() {
stopGeneration()
}
async function handleEdit(messageId: string, newContent: string) {
await editAndResend(messageId, newContent)
}
async function handleRegenerate() {
await regenerateLastResponse()
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
+109
View File
@@ -355,9 +355,118 @@ export function useAI() {
}
}
/**
* Edit a user message and regenerate the AI response.
* Clears all messages after the edited message, then re-sends.
*/
async function editAndResend(messageId: string, newContent: string) {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv) return
const msgIndex = conv.messages.findIndex((m) => m.id === messageId)
if (msgIndex === -1) return
// Update the message content
chatStore.updateMessageContent(convId, messageId, newContent)
// Delete all messages after this one
chatStore.deleteMessagesAfter(convId, msgIndex + 1)
// Re-send (creates new assistant message and streams)
await resendLastUserMessage()
}
/**
* Regenerate the last assistant response.
* Deletes the last assistant message and re-sends with the same user message.
*/
async function regenerateLastResponse() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
// Find the last assistant message and remove it
const lastIndex = conv.messages.length - 1
if (conv.messages[lastIndex].role === 'assistant') {
chatStore.deleteMessagesAfter(convId, lastIndex)
}
await resendLastUserMessage()
}
/** Internal: re-send from the current last user message */
async function resendLastUserMessage() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
const lastUserMsg = [...conv.messages].reverse().find((m) => m.role === 'user')
if (!lastUserMsg) return
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
const cid = convId
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) {
const results = await searchWeb(lastUserMsg.content)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
}
}
const history: ChatMessage[] = chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
const onError = (err: string) => {
console.error(`[AIUI ${provider}]`, err)
chatStore.appendToLastMessage(cid, `${err}`)
}
try {
if (provider === 'claude') {
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
} finally {
currentAbort = null
chatStore.isStreaming = false
}
}
return {
sendMessage,
stopGeneration,
editAndResend,
regenerateLastResponse,
activeProvider,
activeModel,
availableProviders,
+24
View File
@@ -244,6 +244,28 @@ export const useChatStore = defineStore('chat', () => {
}
}
/** Update a message's content (for editing) */
function updateMessageContent(conversationId: string, messageId: string, newContent: string) {
const conv = conversations.value.get(conversationId)
if (!conv) return
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
if (msg) {
msg.content = newContent
msg.editedAt = Date.now()
conv.updatedAt = Date.now()
debouncedIDBSave(conv)
}
}
/** Delete all messages after the given index (inclusive of afterIndex) */
function deleteMessagesAfter(conversationId: string, afterIndex: number) {
const conv = conversations.value.get(conversationId)
if (!conv) return
conv.messages.splice(afterIndex)
conv.updatedAt = Date.now()
debouncedIDBSave(conv)
}
return {
conversations,
activeConversationId,
@@ -263,5 +285,7 @@ export const useChatStore = defineStore('chat', () => {
toggleChatCollapse,
setActiveConversation,
deleteConversation,
updateMessageContent,
deleteMessagesAfter,
}
})
+2
View File
@@ -21,6 +21,8 @@ export interface Message {
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'
/** Web search results (articles, links) attached when query used web search */
webResults?: WebSearchResult[]
/** Timestamp of last edit (set when user edits a sent message) */
editedAt?: number
}
export interface Reaction {