fe127ec3ed
Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags, AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type { AiSession } from '@shared/types'
|
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
|
|
|
interface AiSessionMenuProps {
|
|
bookId: string
|
|
session: AiSession | null
|
|
}
|
|
|
|
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
|
|
const { t } = useTranslation()
|
|
const loadSessions = useAiStore((s) => s.loadSessions)
|
|
const selectSession = useAiStore((s) => s.selectSession)
|
|
const [open, setOpen] = useState(false)
|
|
const [exportOpen, setExportOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const close = (): void => {
|
|
setOpen(false)
|
|
setExportOpen(false)
|
|
}
|
|
window.addEventListener('click', close)
|
|
return () => window.removeEventListener('click', close)
|
|
}, [open])
|
|
|
|
if (!session) return null
|
|
|
|
const handleRename = async (): Promise<void> => {
|
|
setOpen(false)
|
|
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
|
|
if (!title?.trim() || title.trim() === session.title) return
|
|
await ipcCall(() =>
|
|
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
|
|
)
|
|
await loadSessions(bookId, true)
|
|
}
|
|
|
|
const handleArchiveToggle = async (): Promise<void> => {
|
|
setOpen(false)
|
|
await ipcCall(() =>
|
|
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
|
|
)
|
|
await loadSessions(bookId, true)
|
|
}
|
|
|
|
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
|
|
setOpen(false)
|
|
setExportOpen(false)
|
|
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
|
|
}
|
|
|
|
const handleDelete = async (): Promise<void> => {
|
|
setOpen(false)
|
|
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
|
|
return
|
|
}
|
|
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
|
|
await loadSessions(bookId, true)
|
|
const next = useAiStore.getState().sessions[0]
|
|
if (next) await selectSession(next.id)
|
|
}
|
|
|
|
return (
|
|
<div className="ai-session-menu-wrap">
|
|
<button
|
|
type="button"
|
|
className="btn ai-session-menu-btn"
|
|
data-testid="ai-session-menu-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpen((v) => !v)
|
|
}}
|
|
>
|
|
⋯
|
|
</button>
|
|
{open && (
|
|
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
|
|
<button type="button" onClick={() => void handleRename()}>
|
|
{t('ai.session.rename')}
|
|
</button>
|
|
<button type="button" onClick={() => void handleArchiveToggle()}>
|
|
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setExportOpen((v) => !v)
|
|
}}
|
|
>
|
|
{t('ai.session.export')} ▾
|
|
</button>
|
|
{exportOpen && (
|
|
<div className="ai-session-export-submenu">
|
|
<button type="button" onClick={() => void handleExport('markdown')}>
|
|
{t('ai.session.exportMd')}
|
|
</button>
|
|
<button type="button" onClick={() => void handleExport('txt')}>
|
|
{t('ai.session.exportTxt')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
|
{t('ai.session.delete')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|