fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id

This commit is contained in:
Yun Chan 2026-09-28 00:54:03 +09:00
parent a8983c583a
commit a85b24d385
15 changed files with 974 additions and 69 deletions

View file

@ -24,6 +24,8 @@ import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal'
import { DEFAULT_SETTINGS_TAB, OPEN_SETTINGS_EVENT, readOpenSettingsTab } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
import { AdBanner } from './ads/AdBanner'
@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement {
const { t } = useI18n()
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsInitialTab, setSettingsInitialTab] = useState<number>(0)
const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTabId>(DEFAULT_SETTINGS_TAB)
const [onboardingOpen, setOnboardingOpen] = useState(false)
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
const [supportModalOpen, setSupportModalOpen] = useState(false)
@ -107,16 +109,12 @@ export function AppLayout(): React.ReactElement {
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
const handleOpenSettings = (e?: Event) => {
const customEvent = e as CustomEvent<{ tab?: number }> | undefined
if (customEvent?.detail?.tab !== undefined) {
setSettingsInitialTab(customEvent.detail.tab)
} else {
setSettingsInitialTab(0)
}
// detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 창 방지)
const handleOpenSettings = (e: Event) => {
setSettingsInitialTab(readOpenSettingsTab(e))
setSettingsOpen(true)
}
window.addEventListener('d3ro:open-settings', handleOpenSettings as EventListener)
window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
const unsubVoiceError = window.electronAPI.voice.onError((e) => {
const severity = e.severity ?? 'error'
@ -146,7 +144,7 @@ export function AppLayout(): React.ReactElement {
unsubUpgradeReq()
unsubVoiceError()
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
}
}, [t])

View file

@ -38,21 +38,23 @@ import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
import { DEFAULT_SETTINGS_TAB, SETTINGS_TAB_ORDER } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
interface SettingsModalProps {
open: boolean
initialTab?: number
initialTab?: SettingsTabId
onClose: () => void
}
interface TabPanelProps {
children: React.ReactNode
value: number
index: number
value: SettingsTabId
tab: SettingsTabId
}
function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null {
if (value !== index) return null
function TabPanel({ children, value, tab }: TabPanelProps): React.ReactElement | null {
if (value !== tab) return null
return <Box sx={{ pt: 2 }}>{children}</Box>
}
@ -65,9 +67,22 @@ const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> =
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input']
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n()
const [activeTab, setActiveTab] = useState(initialTab)
const [activeTab, setActiveTab] = useState<SettingsTabId>(initialTab)
const tabMeta = useMemo<Record<SettingsTabId, { label: string; icon?: React.ReactElement }>>(
() => ({
general: { label: t('settings.tabs.general'), icon: <Keyboard size={14} /> },
audio: { label: t('settings.tabs.audio'), icon: <Mic size={14} /> },
stt: { label: t('settings.tabs.stt') },
llm: { label: t('settings.tabs.llm') },
input: { label: t('input.tab'), icon: <TextCursorInput size={14} /> },
license: { label: t('license.nav'), icon: <Lock size={14} /> },
cloud: { label: t('settings.tabs.cloud') ?? 'Cloud', icon: <Cloud size={14} /> },
about: { label: t('settings.tabs.about') },
}),
[t],
)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(null)
@ -222,7 +237,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 0 }}>
<Tabs
value={activeTab}
onChange={(_, v: number) => setActiveTab(v)}
onChange={(_, v: SettingsTabId) => setActiveTab(v)}
variant="scrollable"
scrollButtons={false}
sx={{
@ -243,19 +258,14 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
}}
>
<Tab label={t('settings.tabs.general')} icon={<Keyboard size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.stt')} />
<Tab label={t('settings.tabs.llm')} />
<Tab label={t('input.tab')} icon={<TextCursorInput size={14} />} iconPosition="start" />
<Tab label={t('license.nav')} icon={<Lock size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.cloud') ?? 'Cloud'} icon={<Cloud size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.about')} />
{SETTINGS_TAB_ORDER.map((tab) => (
<Tab key={tab} value={tab} label={tabMeta[tab].label} icon={tabMeta[tab].icon} iconPosition="start" />
))}
</Tabs>
<Box sx={{ p: 3 }}>
{/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}>
<TabPanel value={activeTab} tab="general">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
@ -403,7 +413,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel>
{/* ── 오디오 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={1}>
<TabPanel value={activeTab} tab="audio">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.microphone')}
@ -539,12 +549,12 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel>
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<TabPanel value={activeTab} tab="stt">
<STTTab config={config} updateConfig={updateConfig} />
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}>
<TabPanel value={activeTab} tab="llm">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* LLM 백엔드 선택 */}
<Box>
@ -802,21 +812,21 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel>
{/* ── 라이선스 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={4}>
<TabPanel value={activeTab} tab="input">
<InputConsentPanel />
</TabPanel>
<TabPanel value={activeTab} index={5}>
<TabPanel value={activeTab} tab="license">
<LicenseTab />
</TabPanel>
{/* ── Cloud Sync 탭 ──────────────────────────── */}
<TabPanel value={activeTab} index={6}>
<TabPanel value={activeTab} tab="cloud">
<CloudSyncSection />
</TabPanel>
{/* ── 정보 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={7}>
<TabPanel value={activeTab} tab="about">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
D3RO-VOICE

View file

@ -15,6 +15,7 @@ import {
import { Lock, CheckCircle2 } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { openSettings } from '../navigation/settingsTabs'
import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types'
export function UpgradePromptModal(): React.ReactElement {
@ -49,7 +50,7 @@ export function UpgradePromptModal(): React.ReactElement {
const openCloudSyncSettings = (): void => {
setOpen(false)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
openSettings('cloud')
}
return (

View file

@ -2,7 +2,7 @@
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments'
import {
Box,
Tabs,
@ -12,6 +12,8 @@ import {
Tooltip,
Typography,
Checkbox,
Snackbar,
Alert,
} from '@mui/material'
import {
ArrowLeft,
@ -50,21 +52,6 @@ interface MeetingDetailTabsProps {
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 }> {
// 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다.
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
id: `seg-${idx}`,
timestamp: line.timestampMs,
text: line.text,
edited: false,
speaker: line.speaker ?? undefined,
}))
}
// Extract Action items from documents
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
const lines = content.split('\n')
@ -112,7 +99,7 @@ export function MeetingDetailTabs({
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
const segments = useMemo(
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
() => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript),
[detail.rawTranscript, detail.editedTranscript],
)
@ -165,14 +152,38 @@ export function MeetingDetailTabs({
}, [detail.id, titleDraft])
// ── 세그먼트 편집 ──
// 세그먼트는 전사 줄에서 파생되므로, 고친 줄로 전사 전체를 재조립해 updateTranscript 로 저장한다.
// 화면은 낙관적으로 먼저 바꾸고, 저장이 실패하면 이전 수정본으로 되돌린다.
const [segmentEditError, setSegmentEditError] = useState<string | null>(null)
const latestTranscriptRef = useRef<{ edited: string | null; raw: string | null }>({
edited: detail.editedTranscript,
raw: detail.rawTranscript,
})
latestTranscriptRef.current = { edited: detail.editedTranscript, raw: detail.rawTranscript }
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 }))
const { edited: previous, raw } = latestTranscriptRef.current
const next = applyTranscriptSegmentEdit(previous || raw, segmentId, newText)
if (next === null) return
latestTranscriptRef.current = { edited: next, raw }
setDetail((prev) => ({ ...prev, editedTranscript: next }))
const rollback = (message: string): void => {
if (latestTranscriptRef.current.edited === next) {
latestTranscriptRef.current = { edited: previous, raw }
setDetail((prev) => (prev.editedTranscript === next ? { ...prev, editedTranscript: previous } : prev))
}
setSegmentEditError(message)
}
try {
const resp = await window.electronAPI.meetingMode.updateTranscript({
sessionId: detail.id,
editedTranscript: next,
})
if (!resp.success) rollback(resp.error.message)
} catch (err: unknown) {
rollback(err instanceof Error ? err.message : String(err))
}
},
[detail.id],
)
@ -639,6 +650,17 @@ export function MeetingDetailTabs({
generating={generating}
onGenerate={handleGenerate}
/>
<Snackbar
open={segmentEditError !== null}
autoHideDuration={4000}
onClose={() => setSegmentEditError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSegmentEditError(null)} sx={{ width: '100%' }}>
{segmentEditError}
</Alert>
</Snackbar>
</Box>
)
}

View file

@ -0,0 +1,62 @@
// src/renderer/components/meeting/transcript-segments.ts
// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환.
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과
// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript'
export interface TranscriptSegmentView {
id: string
timestamp: number
text: string
edited: boolean
speaker?: string
}
const SEGMENT_ID_PREFIX = 'seg-'
export function transcriptSegmentId(index: number): string {
return `${SEGMENT_ID_PREFIX}${index}`
}
/** `seg-<n>` → n. 형식이 아니면 null. */
export function transcriptSegmentIndex(segmentId: string): number | null {
if (!segmentId.startsWith(SEGMENT_ID_PREFIX)) return null
const raw = segmentId.slice(SEGMENT_ID_PREFIX.length)
if (!/^\d+$/.test(raw)) return null
return Number(raw)
}
/** 수정본이 있으면 수정본을, 없으면 원문을 세그먼트로 나눈다. */
export function parseTranscriptSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): TranscriptSegmentView[] {
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
id: transcriptSegmentId(idx),
timestamp: line.timestampMs,
text: line.text,
edited: false,
speaker: line.speaker ?? undefined,
}))
}
/**
* 세그먼트 하나의 텍스트를 바꾼 전사 전체를 돌려준다(시각·화자 표기는 유지).
* 세그먼트를 찾지 못했거나, 내용이 비었거나, 바뀐 것이 없으면 null.
*/
export function applyTranscriptSegmentEdit(
transcript: string | null,
segmentId: string,
newText: string,
): string | null {
const index = transcriptSegmentIndex(segmentId)
if (index === null) return null
const lines = parseTranscriptLines(transcript)
const target = lines[index]
if (!target) return null
const text = newText.replace(/\s*\r?\n\s*/g, ' ').trim()
if (!text || text === target.text) return null
const next = lines.map((line, idx) => (idx === index ? { ...line, text } : line))
return formatTranscriptLines(next)
}

View file

@ -8,6 +8,7 @@ import type {
UsageQuota,
TierComparison,
} from '@d3ro/core/types'
import { openSettings } from '../navigation/settingsTabs'
interface CloudState {
authenticated: boolean
@ -69,7 +70,7 @@ export function useLicenseState(): LicenseState {
}, [])
const openCloudSettings = useCallback(() => {
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
openSettings('cloud')
}, [])
return {

View file

@ -0,0 +1,59 @@
// src/renderer/navigation/settingsTabs.ts
// 설정 창 딥링크의 단일 진입점.
// 호출자는 SettingsModal 의 탭 순서(숫자 인덱스)가 아니라 이 탭 식별자에 의존한다.
// 'd3ro:open-settings' 이벤트를 보내는 곳(openSettings)과 읽는 곳(readOpenSettingsTab)이 모두 여기 있다.
/** SettingsModal 이 렌더하는 탭 순서 — 탭을 옮기면 이 배열만 바꾼다. */
export const SETTINGS_TAB_ORDER = [
'general',
'audio',
'stt',
'llm',
'input',
'license',
'cloud',
'about',
] as const
export type SettingsTabId = (typeof SETTINGS_TAB_ORDER)[number]
export const DEFAULT_SETTINGS_TAB: SettingsTabId = 'general'
export const OPEN_SETTINGS_EVENT = 'd3ro:open-settings'
export interface OpenSettingsDetail {
tab?: SettingsTabId
}
export function isSettingsTabId(value: unknown): value is SettingsTabId {
return typeof value === 'string' && (SETTINGS_TAB_ORDER as readonly string[]).includes(value)
}
export function settingsTabIndex(tab: SettingsTabId): number {
return SETTINGS_TAB_ORDER.indexOf(tab)
}
/**
* 이벤트 detail.tab 을 탭 식별자로 바꾼다. 타입 없는 DOM 이벤트 경계라 검증한다.
* 예전 호출부가 보내던 숫자 인덱스도 범위 안이면 받아 준다. 그 밖은 기본 탭.
*/
export function resolveSettingsTab(value: unknown): SettingsTabId {
if (isSettingsTabId(value)) return value
if (typeof value === 'number' && Number.isInteger(value)) {
return SETTINGS_TAB_ORDER[value] ?? DEFAULT_SETTINGS_TAB
}
return DEFAULT_SETTINGS_TAB
}
/** 'd3ro:open-settings' 이벤트에서 열 탭을 읽는다(detail 이 없으면 기본 탭). */
export function readOpenSettingsTab(event: Event): SettingsTabId {
const detail: unknown = event instanceof CustomEvent ? event.detail : undefined
if (typeof detail !== 'object' || detail === null) return DEFAULT_SETTINGS_TAB
return resolveSettingsTab((detail as { tab?: unknown }).tab)
}
/** 설정 창을 연다. 탭을 주지 않으면 기본 탭. */
export function openSettings(tab?: SettingsTabId, target: EventTarget = window): void {
const detail: OpenSettingsDetail = tab === undefined ? {} : { tab }
target.dispatchEvent(new CustomEvent<OpenSettingsDetail>(OPEN_SETTINGS_EVENT, { detail }))
}

View file

@ -49,6 +49,13 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { openSettings } from '../navigation/settingsTabs'
import {
isActiveMeetingState,
recordingStartFromElapsed,
replaceSegmentText,
rollbackSegmentText,
} from './meeting/live-session'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
@ -98,6 +105,10 @@ export function MeetingModePage(): React.ReactElement {
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
const segmentsRef = useRef<CaptionSegment[]>(segments)
segmentsRef.current = segments
/** startRecording 응답의 세션 id — stateChanged 이벤트보다 먼저 편집이 와도 쓸 수 있게 */
const [liveSessionId, setLiveSessionId] = useState<string | null>(null)
const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0)
@ -134,6 +145,28 @@ export function MeetingModePage(): React.ReactElement {
checkPreflight()
}, [checkPreflight])
// ── 진행 중 세션 복원 ──
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
useEffect(() => {
let cancelled = false
window.electronAPI.meetingMode
.getState()
.then((resp) => {
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
const info = resp.data
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
setStateInfo(info)
setLiveSessionId(info.sessionId)
setElapsedMs(info.elapsedMs)
setView('recording')
})
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
return () => {
cancelled = true
}
}, [])
// ── 에러 파서 ──
const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => {
const msg = (errMessage ?? '').toLowerCase()
@ -268,7 +301,8 @@ export function MeetingModePage(): React.ReactElement {
// ── 경과 시간 타이머 ──
useEffect(() => {
if (view === 'recording') {
recordingStartRef.current = Date.now()
// 시작 시각은 뷰로 들어오는 쪽(새 녹음 / 세션 복원)이 정한다
if (!recordingStartRef.current) recordingStartRef.current = Date.now()
elapsedTimerRef.current = setInterval(() => {
setElapsedMs(Date.now() - recordingStartRef.current)
}, 1000)
@ -288,6 +322,8 @@ export function MeetingModePage(): React.ReactElement {
try {
const resp = await window.electronAPI.meetingMode.startRecording({ force })
if (resp.success) {
recordingStartRef.current = Date.now()
setLiveSessionId(resp.data.sessionId)
setSegments([])
setMemos([])
setElapsedMs(0)
@ -311,20 +347,33 @@ export function MeetingModePage(): React.ReactElement {
}, [])
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
const liveSessionIdForEdit = stateInfo?.sessionId ?? liveSessionId
const handleLiveEditSegment = useCallback(
async (segmentId: string, newText: string) => {
setSegments((prev) =>
prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)),
)
if (stateInfo?.sessionId) {
await window.electronAPI.meetingMode.editSegment({
sessionId: stateInfo.sessionId,
const previousText = segmentsRef.current.find((s) => s.id === segmentId)?.text
if (previousText === undefined || previousText === newText) return
setSegments((prev) => replaceSegmentText(prev, segmentId, newText))
const rollback = (message: string): void => {
setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText))
setSnackbarMsg(message)
}
if (!liveSessionIdForEdit) {
rollback(t('meeting.error'))
return
}
try {
const resp = await window.electronAPI.meetingMode.editSegment({
sessionId: liveSessionIdForEdit,
segmentId,
text: newText,
})
if (!resp.success) rollback(resp.error.message)
} catch (err: unknown) {
rollback(err instanceof Error ? err.message : String(err))
}
},
[stateInfo?.sessionId],
[liveSessionIdForEdit, t],
)
const isAddingMemoRef = useRef(false)
@ -507,7 +556,7 @@ export function MeetingModePage(): React.ReactElement {
size="small"
onClick={() => {
setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } }))
openSettings('stt')
}}
leadingIcon={<Settings size={13} />}
>
@ -521,7 +570,7 @@ export function MeetingModePage(): React.ReactElement {
size="small"
onClick={() => {
setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } }))
openSettings('audio')
}}
leadingIcon={<Mic size={13} />}
>
@ -706,6 +755,7 @@ export function MeetingModePage(): React.ReactElement {
</Box>
)}
{snackbar}
{errorDialog}
</Box>
)
}

View file

@ -0,0 +1,44 @@
// src/renderer/pages/meeting/live-session.ts
// 회의 녹음 뷰의 순수 정책 — 페이지가 다시 마운트될 때 진행 중 세션 복원, 실시간 세그먼트 편집.
import type { CaptionSegment, MeetingModeState } from '@d3ro/core/types'
/** main 에서 녹음 또는 후처리가 진행 중이면 녹음 뷰를 보여 줘야 한다(정지·진행률 표시). */
export function isActiveMeetingState(state: MeetingModeState | null | undefined): boolean {
return state === 'recording' || state === 'processing'
}
/** main 이 알려 준 경과 시간으로 녹음 시작 시각을 되짚는다(경과 타이머·세그먼트 상대 시각 기준). */
export function recordingStartFromElapsed(elapsedMs: number, now: number): number {
return now - Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0)
}
/** 세그먼트 하나의 텍스트를 바꾼 새 배열. 해당 세그먼트가 없으면 원래 배열 그대로. */
export function replaceSegmentText(
segments: readonly CaptionSegment[],
segmentId: string,
text: string,
): CaptionSegment[] {
let changed = false
const next = segments.map((s) => {
if (s.id !== segmentId || s.text === text) return s
changed = true
return { ...s, text }
})
return changed ? next : (segments as CaptionSegment[])
}
/**
* 낙관적 편집을 되돌린다 — 그 사이 같은 세그먼트를 다시 고쳤다면(현재 텍스트가 실패한 편집과 다르면)
* 최신 편집을 덮어쓰지 않는다.
*/
export function rollbackSegmentText(
segments: readonly CaptionSegment[],
segmentId: string,
failedText: string,
previousText: string,
): CaptionSegment[] {
const current = segments.find((s) => s.id === segmentId)
if (!current || current.text !== failedText) return segments as CaptionSegment[]
return replaceSegmentText(segments, segmentId, previousText)
}