From 10f5dacc78a937d2de5c4bb99c3f468e0ff24d1a Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 10 Aug 2026 17:59:21 +0800 Subject: [PATCH 1/2] fix(dc-ui): restore copy label and keep focus Render the label attr as the default-slot fallback again so text consumers (CodeArtifact, McpJsonViewer, McpServers, TraceDialog) keep their visible caption, and drop the component-level icon key so the success icon swap no longer recreates the button and steals keyboard focus. --- src/dc-ui/components/button/DcCopyButton.vue | 9 +++++-- test/renderer/components/DcCopyButton.test.ts | 26 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/dc-ui/components/button/DcCopyButton.vue b/src/dc-ui/components/button/DcCopyButton.vue index e508e8f3d..b6f7bca99 100644 --- a/src/dc-ui/components/button/DcCopyButton.vue +++ b/src/dc-ui/components/button/DcCopyButton.vue @@ -67,13 +67,18 @@ export default defineComponent({ const { copyText: _copyText, ...buttonProps } = props const accessibleName = inheritedAttrs.label ?? inheritedAttrs.tooltip ?? _copyText const inheritedClick = inheritedAttrs.onClick + const fallbackSlots = { + ...slots, + default: + slots.default ?? + (() => (typeof inheritedAttrs.label === 'string' ? inheritedAttrs.label : undefined)) + } return h( DcButton, { ...inheritedAttrs, ...buttonProps, - key: icon.value, icon: icon.value, iconClass: cn( inheritedAttrs.iconClass, @@ -90,7 +95,7 @@ export default defineComponent({ void copyText() } }, - slots + fallbackSlots ) } } diff --git a/test/renderer/components/DcCopyButton.test.ts b/test/renderer/components/DcCopyButton.test.ts index 52aa9de84..79731eb7a 100644 --- a/test/renderer/components/DcCopyButton.test.ts +++ b/test/renderer/components/DcCopyButton.test.ts @@ -29,30 +29,50 @@ describe('DcCopyButton', () => { }) afterEach(() => { + document.body.innerHTML = '' vi.useRealTimers() }) - it('copies copyText, emits copied, and resets the success state', async () => { + it('renders label as visible fallback text', () => { const wrapper = mount(DcCopyButton, { props: { copyText: 'hello' }, attrs: { - label: 'Copy' + label: 'Copy visible text' } }) - await wrapper.get('button').trigger('click') + expect(wrapper.text()).toContain('Copy visible text') + }) + + it('copies copyText, emits copied, preserves focus, and resets the success state', async () => { + const wrapper = mount(DcCopyButton, { + props: { + copyText: 'hello' + }, + attrs: { + label: 'Copy' + }, + attachTo: document.body + }) + + const button = wrapper.get('button') + button.element.focus() + + await button.trigger('click') await vi.waitFor(() => expect(copyMock).toHaveBeenCalledWith('hello')) await vi.waitFor(() => expect(wrapper.emitted('copied')).toHaveLength(1)) expect(wrapper.find('[data-icon="lucide:check"]').exists()).toBe(true) expect(wrapper.get('button').classes()).toContain('text-emerald-600') + expect(document.activeElement).toBe(button.element) await vi.advanceTimersByTimeAsync(1200) await nextTick() expect(wrapper.find('[data-icon="lucide:copy"]').exists()).toBe(true) + expect(document.activeElement).toBe(button.element) }) it('emits error when clipboard copying fails', async () => { From 3da3364653f053cb252baf33d1294e459308b1d7 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 10 Aug 2026 17:59:21 +0800 Subject: [PATCH 2/2] fix(chat): restore and trim composer drafts Load the persisted draft while useComposerSubmit is constructed: the ChatPage session watcher fires before the composable exists and each session switch remounts ChatPage, so switchComposerSession alone never restored a stored draft and dispose could flush empty state over it. Strip base64 content, thumbnails and resolved representations from persisted files so localStorage holds bounded descriptors instead of raw attachment payloads. --- .../composables/useComposerSubmit.ts | 24 ++++++++++++++-- .../model/composerDraftPersistence.ts | 28 ++++++++++++++++++- .../composables/useComposerSubmit.test.ts | 26 ++++++++++++++++- .../model/composerDraftPersistence.test.ts | 26 +++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts index 480e47659..883b4a317 100644 --- a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts +++ b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts @@ -212,10 +212,30 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { let pendingHandleRestoreSessionId: string | null = null let searchCapabilityRequestId = 0 - const initialDraft = createEmptyComposerDraft() + const storedInitialDraft = loadComposerDraftFromStorage(activeDraftSessionId) + const initialDraft = storedInitialDraft ?? createEmptyComposerDraft() + if (storedInitialDraft) { + sessionDrafts.set(activeDraftSessionId, copyComposerDraft(storedInitialDraft)) + } draftRevisions.set(activeDraftSessionId, initialDraft.revision) observedDraftFingerprints.set(activeDraftSessionId, composerDraftFingerprint(initialDraft)) - observedSkillSelections.set(activeDraftSessionId, []) + observedSkillSelections.set(activeDraftSessionId, [...initialDraft.activeSkills]) + if (storedInitialDraft) { + message.value = storedInitialDraft.rawMessage + attachedFiles.value = copyComposerFiles(storedInitialDraft.files) + if (chatInputRef.value) { + if (storedInitialDraft.activeSkills.length === 0) { + chatInputRef.value.clearPendingSkills?.() + } else { + chatInputRef.value.setPendingSkills?.([...storedInitialDraft.activeSkills]) + } + chatInputRef.value.restoreDocumentSnapshot?.( + copyComposerDocument(storedInitialDraft.document) + ) + } else { + pendingHandleRestoreSessionId = activeDraftSessionId + } + } const attachmentPreparationSummary = computed( () => blockedComposerAttempts.get(options.sessionId())?.summary ?? null diff --git a/src/renderer/src/features/chat-page/model/composerDraftPersistence.ts b/src/renderer/src/features/chat-page/model/composerDraftPersistence.ts index ad8559815..2ac0e4736 100644 --- a/src/renderer/src/features/chat-page/model/composerDraftPersistence.ts +++ b/src/renderer/src/features/chat-page/model/composerDraftPersistence.ts @@ -86,6 +86,32 @@ function isMessageFile(value: unknown): value is MessageFile { ) } +function toPersistableFile(file: MessageFile): MessageFile { + return { + name: file.name, + path: file.path, + ...(file.type !== undefined ? { type: file.type } : {}), + ...(file.size !== undefined ? { size: file.size } : {}), + ...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}), + ...(file.token !== undefined ? { token: file.token } : {}), + ...(file.requestedRepresentation !== undefined + ? { requestedRepresentation: file.requestedRepresentation } + : {}), + ...(file.pdfTextCoverage !== undefined ? { pdfTextCoverage: file.pdfTextCoverage } : {}), + ...(file.metadata !== undefined ? { metadata: file.metadata } : {}) + } +} + +function toPersistableDraft(draft: ComposerSessionDraft): ComposerSessionDraft { + return { + revision: draft.revision, + rawMessage: draft.rawMessage, + files: draft.files.map(toPersistableFile), + activeSkills: [...draft.activeSkills], + document: draft.document + } +} + function parseComposerDraft(value: unknown): ComposerSessionDraft | null { if ( !isRecord(value) || @@ -139,7 +165,7 @@ export function saveComposerDraftToStorage(sessionId: string, draft: ComposerSes storage.removeItem(storageKey(sessionId)) return } - storage.setItem(storageKey(sessionId), JSON.stringify(draft)) + storage.setItem(storageKey(sessionId), JSON.stringify(toPersistableDraft(draft))) } catch { // Storage can be unavailable (private mode, quota). Draft persistence is best-effort. } diff --git a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts index e90eb5748..28ea50b53 100644 --- a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts +++ b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts @@ -2,7 +2,10 @@ import { computed, effectScope, nextTick, ref, shallowReactive } from 'vue' import type { JSONContent } from '@tiptap/core' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useComposerSubmit } from '@/features/chat-page/composables/useComposerSubmit' -import { saveComposerDraftToStorage } from '@/features/chat-page/model/composerDraftPersistence' +import { + loadComposerDraftFromStorage, + saveComposerDraftToStorage +} from '@/features/chat-page/model/composerDraftPersistence' import type { AttachmentPreparationSummary, ChatMessageRecord, @@ -1034,6 +1037,27 @@ describe('useComposerSubmit attachment preflight', () => { } }) + it('restores a persisted draft during initial mount', () => { + saveComposerDraftToStorage('s1', { + revision: 2, + rawMessage: 'draft from previous mount', + files: [], + activeSkills: [], + document: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'draft from previous mount' }] } + ] + } + }) + + const harness = createHarness() + + expect(harness.actions.message.value).toBe('draft from previous mount') + harness.stop() + expect(loadComposerDraftFromStorage('s1')?.rawMessage).toBe('draft from previous mount') + }) + it('restores a persisted draft when switching to its session', () => { const harness = createHarness() saveComposerDraftToStorage('s2', { diff --git a/test/renderer/features/chat-page/model/composerDraftPersistence.test.ts b/test/renderer/features/chat-page/model/composerDraftPersistence.test.ts index b002fa7fc..e455b6355 100644 --- a/test/renderer/features/chat-page/model/composerDraftPersistence.test.ts +++ b/test/renderer/features/chat-page/model/composerDraftPersistence.test.ts @@ -32,6 +32,32 @@ describe('composerDraftPersistence', () => { expect(loadComposerDraftFromStorage('s1')).toEqual(draft) }) + it('stores only bounded attachment descriptors', () => { + saveComposerDraftToStorage('s1', { + ...createDraft('with file'), + files: [ + { + name: 'image.png', + path: '/tmp/image.png', + mimeType: 'image/png', + content: 'raw-base64-image', + thumbnail: 'raw-base64-thumbnail', + resolvedRepresentation: { kind: 'image' } + } + ] + }) + + const raw = localStorage.getItem('deepchat.composerDraft.v1.s1') ?? '' + expect(raw).not.toContain('raw-base64-image') + expect(raw).not.toContain('raw-base64-thumbnail') + const loaded = loadComposerDraftFromStorage('s1') + expect(loaded?.files[0]).toEqual({ + name: 'image.png', + path: '/tmp/image.png', + mimeType: 'image/png' + }) + }) + it('does not store empty drafts and clears an existing key', () => { saveComposerDraftToStorage('s1', createDraft('filled')) saveComposerDraftToStorage('s1', createEmptyComposerDraft())