feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -1,7 +1,7 @@
// src/renderer/components/meeting/AddDocumentDialog.tsx
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import {
Dialog,
DialogTitle,
@ -44,18 +44,26 @@ export function AddDocumentDialog({
const [selectedId, setSelectedId] = useState<string | null>(null)
const [customPrompt, setCustomPrompt] = useState('')
const [customTitle, setCustomTitle] = useState('')
const isGeneratingRef = useRef(false)
useEffect(() => {
if (!open || !generating) {
isGeneratingRef.current = false
}
}, [open, generating])
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
const isCustom = selectedTemplate?.templateType === 'custom'
const handleGenerate = useCallback(() => {
if (!selectedId) return
if (!selectedId || generating || isGeneratingRef.current) return
isGeneratingRef.current = true
onGenerate(
selectedId,
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
)
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
}, [selectedId, generating, isCustom, customPrompt, customTitle, onGenerate])
const handleClose = useCallback(() => {
if (!generating) {

View file

@ -7,7 +7,7 @@ import { Send, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { MeetingChatMessage } from '@d3ro/core/types'
@ -85,12 +85,14 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
setMessages((prev) => [...prev, msg])
setStreaming(false)
setStreamingContent('')
isSendingChatRef.current = false
})
const unsubError = window.electronAPI.meetingChat.onError((err) => {
setStreaming(false)
setStreamingContent('')
setError(err.message)
isSendingChatRef.current = false
})
return () => {
@ -100,9 +102,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
}
}, [sessionId])
const isSendingChatRef = useRef(false)
const handleSend = useCallback(() => {
const text = inputValue.trim()
if (!text || streaming) return
if (!text || streaming || isSendingChatRef.current) return
isSendingChatRef.current = true
const userMsg: MeetingChatMessage = {
role: 'user',
@ -111,8 +116,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
}
setMessages((prev) => [...prev, userMsg])
setInputValue('')
setStreaming(true)
window.electronAPI.meetingChat.send({ sessionId, message: text })
inputRef.current?.focus()
setTimeout(() => {
isSendingChatRef.current = false
}, 500)
}, [inputValue, streaming, sessionId])
const handleKeyDown = useCallback(
@ -179,13 +188,15 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
</PhosphorText>
<Tooltip title={t('meeting.chatClear')}>
<IconButton
size="small"
onClick={handleClear}
disabled={messages.length === 0 && !streaming}
>
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
</IconButton>
<span>
<IconButton
size="small"
onClick={handleClear}
disabled={messages.length === 0 && !streaming}
>
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={collapsed ? t('meeting.chatExpand') : t('meeting.chatCollapse')}>

View file

@ -1,7 +1,7 @@
// src/renderer/components/meeting/MeetingDetailTabs.tsx
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
Box,
Tabs,
@ -9,15 +9,31 @@ import {
IconButton,
TextField,
Tooltip,
Typography,
Checkbox,
} from '@mui/material'
import { ArrowLeft, Plus, Pencil, Check } from 'lucide-react'
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 } from '@d3ro/ui/components/ds'
import {
PhosphorText,
PhysicalButton,
SegmentControl,
TactileBadge,
MetalCard,
} from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
MeetingSessionDetail,
@ -31,15 +47,15 @@ interface MeetingDetailTabsProps {
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 }> {
const source = rawTranscript ?? ''
const edited = editedTranscript ?? ''
// [MM:SS] 텍스트 패턴 파싱 시도
const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/
): 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())
@ -48,26 +64,43 @@ function parseSegments(
if (m) {
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const text = m[3]
// 수정본이 있으면 edited 판단
const editedLine = edited.split('\n')[idx]?.trim() ?? ''
const speaker = m[3] || undefined
const text = m[4]
return {
id: `seg-${idx}`,
timestamp: (min * 60 + sec) * 1000,
text,
edited: Boolean(editedLine) && editedLine !== line.trim(),
edited: false,
speaker,
}
}
// 타임스탬프 없는 라인: 인덱스 기반
return {
id: `seg-${idx}`,
timestamp: idx * 5000,
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,
@ -76,14 +109,26 @@ export function MeetingDetailTabs({
const [detail, setDetail] = useState<MeetingSessionDetail>(initialDetail)
const [documents, setDocuments] = useState<MeetingDocument[]>((initialDetail.documents ?? []).filter(Boolean))
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
const [tabIndex, setTabIndex] = useState(0)
// Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items')
const [activeTabKey, setActiveTabKey] = useState<string>(() => {
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<ViewMode>('split')
// 세그먼트 파싱 (rawTranscript 기반)
// Granola-style Scratchpad & Action Items State
const [scratchpadNotes, setScratchpadNotes] = useState('')
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
const [enhancingNotes, setEnhancingNotes] = useState(false)
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
const segments = useMemo(
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
[detail.rawTranscript, detail.editedTranscript],
@ -96,6 +141,30 @@ export function MeetingDetailTabs({
})
}, [])
// 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
@ -115,7 +184,6 @@ export function MeetingDetailTabs({
segmentId,
text: newText,
})
// 로컬 editedTranscript 갱신 (단순 반영)
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
},
[detail.id],
@ -133,7 +201,7 @@ export function MeetingDetailTabs({
[detail.id],
)
// ── 문서 내용 자동저장 (DocumentTab 내부에 debounce 있음, 이중 래핑 제거) ──
// ── 문서 내용 자동저장 ──
const handleDocContentChange = useCallback(
async (docId: string, content: string) => {
await window.electronAPI.meetingMode.updateDocument({
@ -147,13 +215,22 @@ export function MeetingDetailTabs({
[],
)
const isExportingRef = useRef(false)
const isDeletingDocRef = useRef(false)
// ── 문서 내보내기 ──
const handleExportDocument = useCallback(
async (docId: string, format: MeetingExportFormat) => {
await window.electronAPI.meetingMode.exportDocument({
documentId: docId,
format,
})
if (isExportingRef.current) return
isExportingRef.current = true
try {
await window.electronAPI.meetingMode.exportDocument({
documentId: docId,
format,
})
} finally {
isExportingRef.current = false
}
},
[],
)
@ -161,32 +238,66 @@ export function MeetingDetailTabs({
// ── 문서 삭제 ──
const handleDeleteDocument = useCallback(
async (docId: string) => {
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
setDocuments((prev) => {
const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭
setTabIndex((currentTab) => {
if (currentTab === docTabIndex) return 0
if (currentTab > docTabIndex) return currentTab - 1
return currentTab
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
})
return prev.filter((d) => d.id !== docId)
})
} finally {
isDeletingDocRef.current = false
}
},
[],
)
// ── 화자 구분 ──
const handleDiarize = useCallback(async () => {
if (diarizing) return
setDiarizing(true)
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)
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)
}
setDiarizing(false)
}, [detail.id])
}, [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)
@ -198,155 +309,347 @@ export function MeetingDetailTabs({
})
setGenerating(false)
if (resp.success) {
const newDoc = resp.data
setDocuments((prev) => {
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
setTabIndex(prev.length + 1)
return [...prev, newDoc]
})
setDocuments((prev) => [...prev, resp.data])
setActiveTabKey(`doc-${resp.data.id}`)
setDialogOpen(false)
}
},
[detail.id],
)
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
const totalTabCount = 1 + documents.length + 1
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 헤더 */}
{/* ── 1. Header Toolbar ──────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
justifyContent: 'space-between',
px: 2.5,
py: 1.25,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.sidebar,
flexShrink: 0,
}}
>
<IconButton size="small" onClick={onBack}>
<ArrowLeft size={18} style={{ color: d3roPalette.text.secondary }} />
</IconButton>
{/* Left: Back & Title */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0, flex: 1 }}>
<IconButton size="small" onClick={onBack} sx={{ color: d3roPalette.text.secondary }}>
<ArrowLeft size={18} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') handleSaveTitle()
}}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
}}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<Check size={16} style={{ color: d3roPalette.accent.main }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, maxWidth: 400 }}>
<TextField
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<Pencil size={15} style={{ color: d3roPalette.text.inactive }} />
value={titleDraft}
onChange={(e) => 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
/>
<IconButton size="small" onClick={handleSaveTitle} sx={{ color: d3roPalette.accent.light }}>
<Check size={16} />
</IconButton>
</Tooltip>
</>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 700,
color: d3roPalette.text.primary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{detail.title || t('meeting.untitled')}
</Typography>
<Tooltip title={t('common.edit')}>
<IconButton size="small" onClick={() => setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}>
<Pencil size={13} />
</IconButton>
</Tooltip>
</Box>
)}
<TactileBadge mono tone="mono">
{new Date(detail.startedAt || Date.now()).toLocaleDateString()}
</TactileBadge>
</Box>
{/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<SegmentControl
options={[
{ value: 'split', label: 'Split ◫' },
{ value: 'transcript', label: 'Transcript ▤' },
{ value: 'doc', label: 'Minutes ▥' },
]}
value={viewMode}
onChange={(v) => setViewMode(v as ViewMode)}
size="small"
/>
<PhysicalButton
tone="accent"
size="small"
onClick={() => {
if (!dialogOpen && !generating) setDialogOpen(true)
}}
disabled={dialogOpen || generating}
trailingIcon={<Plus size={13} />}
>
{t('meeting.addDocument')}
</PhysicalButton>
</Box>
</Box>
{/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Left Canvas: Interactive Transcript Timeline */}
{(viewMode === 'split' || viewMode === 'transcript') && (
<Box
sx={{
width: viewMode === 'split' ? '45%' : '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRight: viewMode === 'split' ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
bgcolor: d3roPalette.bg.app,
p: 2,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, pb: 1, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Users size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 700 }}>
SPEAKER TRANSCRIPT TIMELINE
</PhosphorText>
</Box>
<TactileBadge mono tone="mono">
{segments.length} UTTERANCES
</TactileBadge>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos ?? []}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
onDiarize={handleDiarize}
diarizing={diarizing}
/>
</Box>
</Box>
)}
{/* Right Canvas: Structured AI Minutes & Granola Notepad */}
{(viewMode === 'split' || viewMode === 'doc') && (
<Box
sx={{
width: viewMode === 'split' ? '55%' : '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
bgcolor: d3roPalette.bg.sidebar,
overflow: 'hidden',
}}
>
{/* Right Sub-Tabs */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
pt: 1,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.card,
}}
>
<Tabs
value={activeTabKey}
onChange={(_, v) => 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) => (
<Tab key={doc.id} value={`doc-${doc.id}`} label={doc.title || `Document ${idx + 1}`} />
))}
<Tab value="notepad" label="📝 Granola Notepad" />
<Tab value="action-items" label={`✅ Action Items (${actionItems.length})`} />
</Tabs>
</Box>
{/* Document Content View */}
<Box sx={{ flex: 1, p: 2.5, minHeight: 0, overflow: 'auto' }}>
{activeTabKey.startsWith('doc-') ? (
// AI Document Editor Tab
(() => {
const targetDocId = activeTabKey.replace('doc-', '')
const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0]
return currentDoc ? (
<DocumentTab
document={currentDoc}
onContentChange={(c) => handleDocContentChange(currentDoc.id, c)}
onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)}
onDelete={() => handleDeleteDocument(currentDoc.id)}
/>
) : (
<Box sx={{ py: 8, textAlign: 'center' }}>
<PhosphorText variant="dim"> . .</PhosphorText>
</Box>
)
})()
) : activeTabKey === 'notepad' ? (
// Granola-style Notepad Tab
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
Granola-style Hybrid Notepad
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}>
. AI가 .
</Typography>
</Box>
<PhysicalButton
tone="accent"
size="small"
onClick={handleEnhanceScratchpad}
disabled={!scratchpadNotes.trim() || enhancingNotes}
>
<Sparkles size={14} style={{ marginRight: 4 }} />
{enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'}
</PhysicalButton>
</Box>
<TextField
multiline
rows={14}
value={scratchpadNotes}
onChange={(e) => setScratchpadNotes(e.target.value)}
placeholder="- 논의된 주요 이슈 요약&#10;- 고객사 피드백 핵심 포인트&#10;- 내가 해야 할 일 체크리스트..."
fullWidth
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontSans,
fontSize: '13px',
lineHeight: 1.7,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
p: 2,
},
}}
/>
</Box>
) : (
// Interactive Action Items Checklist Tab
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
Interactive Action Items Checklist
</Typography>
<PhysicalButton
tone="glass"
size="small"
onClick={() => {
const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n')
navigator.clipboard.writeText(formatted)
}}
>
<Copy size={13} style={{ marginRight: 4 }} />
Copy for Slack / Notion
</PhysicalButton>
</Box>
{actionItems.length === 0 ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<PhosphorText variant="dim"> .</PhosphorText>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{actionItems.map((item) => (
<MetalCard
key={item.id}
inset
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 1.5,
bgcolor: item.done ? d3roPalette.bg.inset : d3roPalette.bg.card,
opacity: item.done ? 0.6 : 1,
}}
>
<Checkbox
checked={item.done}
onChange={(e) => {
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 }}
/>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.primary,
textDecoration: item.done ? 'line-through' : 'none',
flex: 1,
}}
>
{item.text}
</Typography>
</MetalCard>
))}
</Box>
)}
</Box>
)}
</Box>
</Box>
)}
</Box>
{/* 탭 바 */}
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
<Tabs
value={tabIndex}
onChange={(_e, v: number) => {
// 마지막 탭([+])은 다이얼로그 열기
if (v === totalTabCount - 1) {
setDialogOpen(true)
return
}
setTabIndex(v)
}}
sx={{
minHeight: 40,
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
minHeight: 40,
py: 0,
color: d3roPalette.text.inactive,
textTransform: 'uppercase',
letterSpacing: '1px',
'&.Mui-selected': { color: d3roPalette.accent.main },
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
}}
>
<Tab label={t('meeting.transcriptTab')} />
{documents.filter(Boolean).map((doc) => (
<Tab key={doc.id} label={doc.title ?? t('meeting.untitled')} />
))}
<Tab
icon={<Plus size={16} />}
label={t('meeting.addDocument')}
iconPosition="start"
sx={{ minWidth: 120 }}
/>
</Tabs>
</Box>
{/* 탭 콘텐츠 */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
{tabIndex === 0 && (
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
onDiarize={handleDiarize}
diarizing={diarizing}
/>
)}
{documents.filter(Boolean).map((doc, idx) => {
if (!doc || tabIndex !== idx + 1) return null
return (
<DocumentTab
key={doc.id}
document={doc}
onContentChange={(content) => handleDocContentChange(doc.id, content)}
onExport={(format) => handleExportDocument(doc.id, format)}
onDelete={() => handleDeleteDocument(doc.id)}
/>
)
})}
</Box>
{/* AI 채팅 패널 */}
{/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */}
<MeetingChatPanel sessionId={detail.id} />
{/* 문서 생성 다이얼로그 */}
{/* Add Document Modal */}
<AddDocumentDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onGenerate={handleGenerate}
templates={templates}
generating={generating}
onGenerate={handleGenerate}
/>
</Box>
)