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

@ -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)
}