feat(chat): add conversation export & three-dot menu (M8.7)

Export conversations as Markdown, JSON, or plain text via three-dot
menu in chat header. Uses File System Access API with <a download>
fallback. Also adds delete conversation option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:13:10 +00:00
co-authored by Claude Opus 4.6
parent 87cbd8c4ea
commit 6182adb01c
2 changed files with 174 additions and 0 deletions
@@ -45,6 +45,17 @@
</svg>
</button>
<button
ref="menuTriggerRef"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="Conversation menu"
@click="showMenu = !showMenu"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="New conversation"
@@ -149,6 +160,47 @@
</div>
</Transition>
</Teleport>
<!-- Conversation menu (export, delete) -->
<Teleport to="body">
<div v-if="showMenu" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showMenu = false" />
<Transition name="picker">
<div
v-if="showMenu"
class="fixed z-[9999] path-glass-card p-2 animate-fade-up-fast shadow-2xl min-w-[160px]"
:style="menuDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-2 text-white/40">Export</p>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('markdown')"
>
Markdown (.md)
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('json')"
>
JSON
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('text')"
>
Plain text (.txt)
</button>
<div class="border-t border-white/10 mt-1 pt-1">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-white/10 transition-all"
@click="handleDelete"
>
Delete conversation
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -157,6 +209,7 @@ import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
defineProps<{
title: string
@@ -176,6 +229,9 @@ const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
const showChatList = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const chatListTriggerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
@@ -211,8 +267,22 @@ function updateModelPickerPosition() {
})
}
function updateMenuPosition() {
nextTick(() => {
const el = menuTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
menuDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: `${window.innerWidth - r.right}px`,
}
}
})
}
watch(showChatList, (v) => { if (v) updateChatListPosition() })
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const activeConversationId = computed(() => chatStore.activeConversationId)
@@ -246,6 +316,20 @@ function openDesignSystem() {
enterDesignSystemMode()
showModelPicker.value = false
}
async function handleExport(format: ExportFormat) {
const conv = chatStore.activeConversation
if (!conv) return
await downloadConversation(conv, format)
showMenu.value = false
}
function handleDelete() {
const id = chatStore.activeConversationId
if (!id) return
chatStore.deleteConversation(id)
showMenu.value = false
}
</script>
<style scoped>
@@ -0,0 +1,90 @@
import type { Conversation } from '@aiui/core/types/message'
export type ExportFormat = 'markdown' | 'json' | 'text'
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString()
}
export function exportAsMarkdown(conv: Conversation): string {
const lines = [`# ${conv.title}\n`, `_Exported ${formatTimestamp(Date.now())}_\n`]
for (const msg of conv.messages) {
const role = msg.role === 'user' ? '**You**' : '**Assistant**'
const time = formatTimestamp(msg.timestamp)
lines.push(`### ${role}${time}\n`)
lines.push(msg.content + '\n')
}
return lines.join('\n')
}
export function exportAsJSON(conv: Conversation): string {
return JSON.stringify(conv, null, 2)
}
export function exportAsText(conv: Conversation): string {
const lines = [conv.title, '='.repeat(conv.title.length), '']
for (const msg of conv.messages) {
const role = msg.role === 'user' ? 'You' : 'Assistant'
lines.push(`[${role}] ${formatTimestamp(msg.timestamp)}`)
lines.push(msg.content)
lines.push('')
}
return lines.join('\n')
}
function getExtension(format: ExportFormat): string {
switch (format) {
case 'markdown': return '.md'
case 'json': return '.json'
case 'text': return '.txt'
}
}
function getMimeType(format: ExportFormat): string {
switch (format) {
case 'markdown': return 'text/markdown'
case 'json': return 'application/json'
case 'text': return 'text/plain'
}
}
export async function downloadConversation(conv: Conversation, format: ExportFormat): Promise<void> {
let content: string
switch (format) {
case 'markdown': content = exportAsMarkdown(conv); break
case 'json': content = exportAsJSON(conv); break
case 'text': content = exportAsText(conv); break
}
const filename = `${conv.title.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase()}${getExtension(format)}`
const blob = new Blob([content], { type: getMimeType(format) })
// Try File System Access API first
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as unknown as { showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle> }).showSaveFilePicker({
suggestedName: filename,
types: [{
description: format === 'markdown' ? 'Markdown' : format === 'json' ? 'JSON' : 'Text',
accept: { [getMimeType(format)]: [getExtension(format)] },
}],
})
const writable = await handle.createWritable()
await writable.write(blob)
await writable.close()
return
} catch {
// User cancelled or API not supported — fall through to download
}
}
// Fallback: <a download>
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}