// src/renderer/components/meeting/MeetingDetailTabs.tsx // Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { Box, Tabs, Tab, IconButton, TextField, Tooltip, Typography, Checkbox, } from '@mui/material' import { ArrowLeft, Plus, Pencil, Check, Users, Copy, Sparkles, } from 'lucide-react' import { TranscriptTab } from './TranscriptTab' import { DocumentTab } from './DocumentTab' import { AddDocumentDialog } from './AddDocumentDialog' import { MeetingChatPanel } from './MeetingChatPanel' import { PhosphorText, PhysicalButton, SegmentControl, TactileBadge, MetalCard, } from '@d3ro/ui/components/ds' import { isImeComposingEvent } from '../../utils/keyboard' import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { MeetingSessionDetail, MeetingDocument, MeetingDocTemplate, MeetingExportFormat, } from '@d3ro/core/types' interface MeetingDetailTabsProps { detail: MeetingSessionDetail onBack: () => void } type ViewMode = 'split' | 'transcript' | 'doc' // 세그먼트 파싱: rawTranscript를 줄 단위로 분리 function parseSegments( rawTranscript: string | null, editedTranscript: string | null, ): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> { const source = editedTranscript || rawTranscript || '' const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/ return source .split('\n') .filter((line) => line.trim()) .map((line, idx) => { const m = regex.exec(line.trim()) if (m) { const min = parseInt(m[1], 10) const sec = parseInt(m[2], 10) const speaker = m[3] || undefined const text = m[4] return { id: `seg-${idx}`, timestamp: (min * 60 + sec) * 1000, text, edited: false, speaker, } } return { id: `seg-${idx}`, timestamp: idx * 4000, text: line.trim(), edited: false, } }) } // Extract Action items from documents function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> { const lines = content.split('\n') const items: Array<{ id: string; text: string; done: boolean }> = [] let idx = 0 for (const line of lines) { const checkMatch = /^\s*[-*]\s*\[([ xX])\]\s*(.+)$/.exec(line) if (checkMatch) { items.push({ id: `todo-${idx++}`, done: checkMatch[1].toLowerCase() === 'x', text: checkMatch[2].trim(), }) } } return items } export function MeetingDetailTabs({ detail: initialDetail, onBack, }: MeetingDetailTabsProps): React.ReactElement { const { t } = useI18n() const [detail, setDetail] = useState(initialDetail) const [documents, setDocuments] = useState((initialDetail.documents ?? []).filter(Boolean)) const [templates, setTemplates] = useState([]) // Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items') const [activeTabKey, setActiveTabKey] = useState(() => { const docs = (initialDetail.documents ?? []).filter(Boolean) return docs.length > 0 ? `doc-${docs[0].id}` : 'notepad' }) const [dialogOpen, setDialogOpen] = useState(false) const [generating, setGenerating] = useState(false) const [diarizing, setDiarizing] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') const [viewMode, setViewMode] = useState('split') // Granola-style Scratchpad & Action Items State const [scratchpadNotes, setScratchpadNotes] = useState('') const [actionItems, setActionItems] = useState>([]) const [enhancingNotes, setEnhancingNotes] = useState(false) // 세그먼트 파싱 (rawTranscript / editedTranscript 기반) const segments = useMemo( () => parseSegments(detail.rawTranscript, detail.editedTranscript), [detail.rawTranscript, detail.editedTranscript], ) // 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시) useEffect(() => { const load = (): void => { window.electronAPI.meetingDocTemplate.getAll().then((resp) => { if (resp.success) setTemplates(resp.data) }) } load() return window.electronAPI.app.onDataChanged((data) => { if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) load() }) }, []) // Sync action items from primary document useEffect(() => { const primaryDoc = documents[0] if (primaryDoc) { const extracted = extractActionItems(primaryDoc.content) if (extracted.length > 0) { setActionItems(extracted) } } }, [documents]) // If documents change, ensure activeTabKey is valid useEffect(() => { if (activeTabKey.startsWith('doc-')) { const targetId = activeTabKey.replace('doc-', '') const exists = documents.some((d) => d.id === targetId) if (!exists && documents.length > 0) { setActiveTabKey(`doc-${documents[0].id}`) } else if (!exists) { setActiveTabKey('notepad') } } }, [documents, activeTabKey]) // ── 제목 저장 ── const handleSaveTitle = useCallback(async () => { if (!titleDraft.trim()) return await window.electronAPI.meetingMode.updateTitle({ sessionId: detail.id, title: titleDraft.trim(), }) setDetail((prev) => ({ ...prev, title: titleDraft.trim() })) setEditingTitle(false) }, [detail.id, titleDraft]) // ── 세그먼트 편집 ── const handleEditSegment = useCallback( async (segmentId: string, newText: string) => { await window.electronAPI.meetingMode.editSegment({ sessionId: detail.id, segmentId, text: newText, }) setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript })) }, [detail.id], ) // ── 전사 저장 ── const handleSaveTranscript = useCallback( async (editedTranscript: string) => { await window.electronAPI.meetingMode.updateTranscript({ sessionId: detail.id, editedTranscript, }) setDetail((prev) => ({ ...prev, editedTranscript })) }, [detail.id], ) // ── 문서 내용 자동저장 ── const handleDocContentChange = useCallback( async (docId: string, content: string) => { await window.electronAPI.meetingMode.updateDocument({ documentId: docId, content, }) setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, content } : d)), ) }, [], ) const isExportingRef = useRef(false) const isDeletingDocRef = useRef(false) // ── 문서 내보내기 ── const handleExportDocument = useCallback( async (docId: string, format: MeetingExportFormat) => { if (isExportingRef.current) return isExportingRef.current = true try { await window.electronAPI.meetingMode.exportDocument({ documentId: docId, format, }) } finally { isExportingRef.current = false } }, [], ) // ── 문서 삭제 ── const handleDeleteDocument = useCallback( async (docId: string) => { if (isDeletingDocRef.current) return isDeletingDocRef.current = true try { await window.electronAPI.meetingMode.deleteDocument({ documentId: docId }) setDocuments((prev) => { const remaining = prev.filter((d) => d.id !== docId) if (remaining.length > 0) { setActiveTabKey(`doc-${remaining[0].id}`) } else { setActiveTabKey('notepad') } return remaining }) } finally { isDeletingDocRef.current = false } }, [], ) // ── 화자 구분 ── const handleDiarize = useCallback(async () => { if (diarizing) return setDiarizing(true) try { const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id }) if (resp.success) { const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id }) if (updated.success) setDetail(updated.data) } } finally { setDiarizing(false) } }, [detail.id, diarizing]) // ── Granola "Jot & Enhance" AI Note Enhancement ── const handleEnhanceScratchpad = useCallback(async () => { if (!scratchpadNotes.trim() || enhancingNotes) return setEnhancingNotes(true) try { const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}` const resp = await window.electronAPI.llm.generate({ prompt }) if (resp.success && resp.data.text) { const newDocResult = await window.electronAPI.meetingMode.generateDocument({ sessionId: detail.id, templateId: 'custom', customTitle: '📝 내 메모 기반 회의록 (AI 증강)', customPrompt: prompt, }) if (newDocResult.success) { setDocuments((prev) => [...prev, newDocResult.data]) setActiveTabKey(`doc-${newDocResult.data.id}`) } } } finally { setEnhancingNotes(false) } }, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes]) // ── 새 문서 생성 다이얼로그 ── const handleGenerate = useCallback( async (templateId: string, customPrompt?: string, customTitle?: string) => { setGenerating(true) const resp = await window.electronAPI.meetingMode.generateDocument({ sessionId: detail.id, templateId, customPrompt, customTitle, }) setGenerating(false) if (resp.success) { setDocuments((prev) => [...prev, resp.data]) setActiveTabKey(`doc-${resp.data.id}`) setDialogOpen(false) } }, [detail.id], ) return ( {/* ── 1. Header Toolbar ──────────────────────────────────────── */} {/* Left: Back & Title */} {editingTitle ? ( setTitleDraft(e.target.value)} onKeyDown={(e) => { if (isImeComposingEvent(e)) return if (e.key === 'Enter') handleSaveTitle() }} sx={{ flex: 1, '& .MuiInputBase-root': { fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 600 }, }} autoFocus /> ) : ( {detail.title || t('meeting.untitled')} setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}> )} {new Date(detail.startedAt || Date.now()).toLocaleDateString()} {/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */} setViewMode(v as ViewMode)} size="small" /> { if (!dialogOpen && !generating) setDialogOpen(true) }} disabled={dialogOpen || generating} trailingIcon={} > {t('meeting.addDocument')} {/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */} {/* Left Canvas: Interactive Transcript Timeline */} {(viewMode === 'split' || viewMode === 'transcript') && ( SPEAKER TRANSCRIPT TIMELINE {segments.length} UTTERANCES )} {/* Right Canvas: Structured AI Minutes & Granola Notepad */} {(viewMode === 'split' || viewMode === 'doc') && ( {/* Right Sub-Tabs */} setActiveTabKey(v)} sx={{ minHeight: 38, '& .MuiTab-root': { fontFamily: d3roFontSans, fontSize: '12px', fontWeight: 600, minHeight: 38, py: 0.5, px: 1.75, color: d3roPalette.text.secondary, '&.Mui-selected': { color: d3roPalette.accent.light }, }, }} > {documents.map((doc, idx) => ( ))} {/* Document Content View */} {activeTabKey.startsWith('doc-') ? ( // AI Document Editor Tab (() => { const targetDocId = activeTabKey.replace('doc-', '') const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0] return currentDoc ? ( handleDocContentChange(currentDoc.id, c)} onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)} onDelete={() => handleDeleteDocument(currentDoc.id)} /> ) : ( 문서가 없습니다. 새 문서를 생성해주세요. ) })() ) : activeTabKey === 'notepad' ? ( // Granola-style Notepad Tab Granola-style Hybrid Notepad 회의 중 메모를 자유롭게 적어보세요. AI가 전체 발화 내용과 결합하여 완벽한 회의록으로 증강해 드립니다. {enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'} setScratchpadNotes(e.target.value)} placeholder="- 논의된 주요 이슈 요약 - 고객사 피드백 핵심 포인트 - 내가 해야 할 일 체크리스트..." fullWidth sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontFamily: d3roFontSans, fontSize: '13px', lineHeight: 1.7, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.inner, p: 2, }, }} /> ) : ( // Interactive Action Items Checklist Tab Interactive Action Items Checklist { const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n') navigator.clipboard.writeText(formatted) }} > Copy for Slack / Notion {actionItems.length === 0 ? ( 문서에서 추출된 액션 아이템이 없습니다. ) : ( {actionItems.map((item) => ( { setActionItems((prev) => prev.map((a) => (a.id === item.id ? { ...a, done: e.target.checked } : a)), ) }} size="small" sx={{ color: d3roPalette.accent.light, p: 0.5 }} /> {item.text} ))} )} )} )} {/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */} {/* Add Document Modal */} setDialogOpen(false)} templates={templates} generating={generating} onGenerate={handleGenerate} /> ) }