feat(ai): support editing sent user messages

* feat(ai): support editing sent user messages

Adds inline edit support for user messages in the AI chat panel.
Hover over a user message to reveal the edit button; confirming the
edit truncates all subsequent history and re-sends with the new content.

* fix(ai): fix IME composition and ref-focus regression in message edit

- Handle IME composition events in onEditKeydown to prevent accidental
  submission on Enter during CJK candidate selection
- Replace per-render :ref focus callback with nextTick + data attribute
  to avoid cursor jumping on every keystroke
- Extract visibleToActualIndex into aiMessageEdit.ts for testability
- Add 8 unit tests covering index mapping with contextSummary messages

* fix(ai): prevent data loss and mention pollution in message edit

- Guard connection/config before truncating messages to prevent silent data
  loss when send() bails early
- Clear selectedMentions before resend to prevent stale chips from polluting
  edited message content
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-03 15:18:56 +08:00
1 parent f1f4518b67
commit fe4908321e
10 files changed
+169 -4

No files matched your search

@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from
import { uuid } from "@/lib/utils";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, GitBranch, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, GitBranch, HelpCircle, History, Loader2, MessageSquarePlus, Pencil, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -38,6 +38,7 @@ import { copyToClipboard } from "@/lib/clipboard";
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/aiTableMentions";
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/aiPromptKeyboard";
import { looksLikeActionProposal, containsChinese } from "@/lib/aiProposalDetect";
import { visibleToActualIndex } from "@/lib/aiMessageEdit";
const { t } = useI18n();
const settings = useSettingsStore();
@@ -87,6 +88,58 @@ const promptHistory = ref<string[]>([]);
const historyIndex = ref(-1);
const draftBeforeHistory = ref("");
const editingMessageIndex = ref<number | null>(null);
const editingContent = ref("");
const editCompositionActive = ref(false);
function startEditMessage(visibleIndex: number) {
if (isGenerating.value) return;
editingMessageIndex.value = visibleIndex;
editingContent.value = visibleMessages.value[visibleIndex].content;
nextTick(() => {
const el = document.querySelector<HTMLTextAreaElement>("[data-edit-textarea]");
if (el) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
});
}
function cancelEdit() {
editingMessageIndex.value = null;
editingContent.value = "";
}
function submitEdit(visibleIndex: number) {
const content = editingContent.value.trim();
if (!content) return;
const actualIndex = visibleToActualIndex(messages.value, visibleIndex);
if (actualIndex < 0) return;
if (!props.connection || !props.tab) return;
if (!settings.isConfigured()) {
toast(t("ai.noConfig"));
return;
}
messages.value = messages.value.slice(0, actualIndex);
editingMessageIndex.value = null;
editingContent.value = "";
selectedMentions.value = [];
prompt.value = content;
send();
}
function onEditKeydown(event: KeyboardEvent, visibleIndex: number) {
if (isAiPromptImeCompositionEvent(event, editCompositionActive.value)) return;
if (event.key === "Escape") {
cancelEdit();
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submitEdit(visibleIndex);
}
}
// Inline model selector
const modelOptions = ref<AiModelInfo[]>([]);
const modelLoading = ref(false);
@@ -1155,9 +1208,33 @@ async function openExternalUrl(url: string) {
<ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
<div class="flex flex-col gap-3 p-3">
<template v-for="(msg, i) in visibleMessages" :key="i">
<div v-if="msg.role === 'user'" class="flex justify-end">
<div class="max-w-[85%] whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
{{ msg.content }}
<div v-if="msg.role === 'user'" class="group flex justify-end">
<div class="max-w-[85%]">
<template v-if="editingMessageIndex === i">
<textarea
data-edit-textarea
v-model="editingContent"
rows="3"
class="w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
@keydown="onEditKeydown($event, i)"
@compositionstart="editCompositionActive = true"
@compositionend="editCompositionActive = false"
/>
<div class="mt-1.5 flex justify-end gap-1.5">
<Button size="sm" variant="ghost" class="h-6 px-2 text-[11px]" @click="cancelEdit">{{ t("ai.editCancel") }}</Button>
<Button size="sm" class="h-6 px-2 text-[11px]" @click="submitEdit(i)">{{ t("ai.editResend") }}</Button>
</div>
</template>
<template v-else>
<div class="flex items-start gap-1">
<button v-if="!isGenerating" class="mt-1 hidden h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground group-hover:flex" :title="t('ai.editMessage')" @click="startEditMessage(i)">
<Pencil class="h-3 w-3" />
</button>
<div class="whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
{{ msg.content }}
</div>
</div>
</template>
</div>
</div>