Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/dc-ui/components/button/DcCopyButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -90,7 +95,7 @@ export default defineComponent({
void copyText()
}
},
slots
fallbackSlots
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment thread
zhangmo8 marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {})
Comment thread
zhangmo8 marked this conversation as resolved.
}
}

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) ||
Expand Down Expand Up @@ -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.
}
Expand Down
26 changes: 23 additions & 3 deletions test/renderer/components/DcCopyButton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down