From 48c35c20e8b731a4694dcd75f42c496000314ee2 Mon Sep 17 00:00:00 2001 From: Qiang Han <70218387+h1679242037@users.noreply.github.com> Date: Sun, 31 May 2026 20:06:18 +0800 Subject: [PATCH] fix(chat): preserve unsent input draft (#1173) * fix(chat): preserve unsent input draft * fix(chat): store drafts by session id --- .../src/components/hermes/chat/ChatInput.vue | 46 ++++++++++ tests/client/chat-input-draft.test.ts | 85 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tests/client/chat-input-draft.test.ts diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 857821e..dcfc5e6 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -16,6 +16,8 @@ const profilesStore = useProfilesStore() const { t } = useI18n() const message = useMessage() const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility() +const DRAFT_STORAGE_KEY = 'hermes_chat_input_drafts_v1' +type DraftMap = Record const inputText = ref('') const textareaRef = ref() const commandDropdownRef = ref() @@ -92,8 +94,43 @@ function startResize(e: MouseEvent) { // 自动播放语音开关 const autoPlaySpeech = ref(false) +function readDraftMap(): DraftMap { + try { + const parsed = JSON.parse(localStorage.getItem(DRAFT_STORAGE_KEY) || '{}') + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +function getActiveDraftSessionId() { + return chatStore.activeSessionId || chatStore.activeSession?.id || '' +} + +function loadDraftForActiveSession() { + const sessionId = getActiveDraftSessionId() + inputText.value = sessionId ? readDraftMap()[sessionId] || '' : '' +} + +function saveDraftForActiveSession(value: string) { + const sessionId = getActiveDraftSessionId() + if (!sessionId) return + const drafts = readDraftMap() + if (value) { + drafts[sessionId] = value + } else { + delete drafts[sessionId] + } + if (Object.keys(drafts).length > 0) { + localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts)) + } else { + localStorage.removeItem(DRAFT_STORAGE_KEY) + } +} + // 从 localStorage 读取设置 onMounted(() => { + loadDraftForActiveSession() const saved = localStorage.getItem('autoPlaySpeech') if (saved !== null) { autoPlaySpeech.value = saved === 'true' @@ -109,6 +146,14 @@ watch(autoPlaySpeech, (value) => { chatStore.setAutoPlaySpeech(value) }) +watch(inputText, (value) => { + saveDraftForActiveSession(value) +}) + +watch(() => chatStore.activeSession?.id, () => { + loadDraftForActiveSession() +}) + const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0) function scrollCommandIntoView() { @@ -354,6 +399,7 @@ function handleSend() { chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined) inputText.value = '' + saveDraftForActiveSession('') attachments.value = [] slashActive.value = false diff --git a/tests/client/chat-input-draft.test.ts b/tests/client/chat-input-draft.test.ts new file mode 100644 index 0000000..7b8ebba --- /dev/null +++ b/tests/client/chat-input-draft.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { nextTick } from 'vue' +import { useChatStore } from '@/stores/hermes/chat' +import ChatInput from '@/components/hermes/chat/ChatInput.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('naive-ui', () => ({ + NButton: { template: '' }, + NTooltip: { template: '
' }, + NSwitch: { template: '' }, + NModal: { template: '
' }, + NInputNumber: { template: '' }, + useMessage: () => ({ error: vi.fn(), success: vi.fn() }), +})) + +vi.mock('@/api/hermes/sessions', () => ({ + fetchContextLength: vi.fn().mockResolvedValue(256000), +})) + +vi.mock('@/api/hermes/model-context', () => ({ + setModelContext: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/composables/useToolTraceVisibility', () => ({ + useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }), +})) + +function mountForSession(sessionId: string) { + const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn }) + const chatStore = useChatStore() + chatStore.sessions = [ + { id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now() }, + ] + chatStore.activeSessionId = sessionId + chatStore.activeSession = chatStore.sessions[0] + return mount(ChatInput, { global: { plugins: [pinia] } }) +} + +describe('ChatInput draft persistence', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('restores unsent text for the active session after the chat view is remounted', async () => { + const wrapper = mountForSession('session-a') + const textarea = wrapper.get('textarea') + + await textarea.setValue('draft before tab switch') + await nextTick() + wrapper.unmount() + + const remounted = mountForSession('session-a') + await nextTick() + + expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch') + }) + + it('stores drafts under one localStorage key mapped by session id', async () => { + const wrapperA = mountForSession('session-a') + await wrapperA.get('textarea').setValue('draft for session a') + await nextTick() + wrapperA.unmount() + + const wrapperB = mountForSession('session-b') + await wrapperB.get('textarea').setValue('draft for session b') + await nextTick() + wrapperB.unmount() + + expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull() + expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({ + 'session-a': 'draft for session a', + 'session-b': 'draft for session b', + }) + + const remountedA = mountForSession('session-a') + await nextTick() + expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a') + }) +})