From fe4908321eb321cf486ed2d773fb91cdb63f01e5 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Fri, 3 Jul 2026 15:18:56 +0800 Subject: [PATCH] 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 --- .../src/components/editor/AiAssistant.vue | 85 ++++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../src/lib/__tests__/aiMessageEdit.spec.ts | 48 +++++++++++ apps/desktop/src/lib/aiMessageEdit.ts | 19 +++++ 10 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/aiMessageEdit.spec.ts create mode 100644 apps/desktop/src/lib/aiMessageEdit.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 8631fb6f8..5d00e8847 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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([]); const historyIndex = ref(-1); const draftBeforeHistory = ref(""); +const editingMessageIndex = ref(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("[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([]); const modelLoading = ref(false); @@ -1155,9 +1208,33 @@ async function openExternalUrl(url: string) {