fix(history): call memo tag API with params objects, render stored summary, and cancel stale audio playback

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent 6347115ae7
commit ed8b585804
3 changed files with 462 additions and 60 deletions

View file

@ -10,6 +10,33 @@ import { useI18n } from '@d3ro/i18n'
import { formatDuration } from '../../utils/formatters'
import { isImeComposingEvent } from '../../utils/keyboard'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
import {
HistoryAudioPlayer,
addEntryTag,
loadEntryTags,
removeEntryTag,
summaryDisplayText,
type HistoryAudioPort,
type HistoryAudioState,
} from './history-entry-card-actions'
/** 렌더러 전역(IPC·Blob URL·HTMLAudioElement)을 재생 포트로 잇는 어댑터 */
const browserHistoryAudioPort: HistoryAudioPort = {
fetchAudio: (historyId) => window.electronAPI.history.getAudio({ id: historyId }),
createObjectUrl: (bytes, mimeType) => URL.createObjectURL(new Blob([bytes.slice()], { type: mimeType })),
revokeObjectUrl: (url) => URL.revokeObjectURL(url),
createAudio: (src, events) => {
const audio = new Audio(src)
audio.onended = () => events.onEnded()
audio.onerror = () => events.onError()
return audio
},
}
interface TagFailure {
kind: 'add' | 'remove'
tag: string
}
export interface HistoryEntryCardProps {
entry: HistoryEntry
@ -40,66 +67,48 @@ export function HistoryEntryCard({
const [summaryLoading, setSummaryLoading] = useState(false)
const [copied, setCopied] = useState(false)
const hasSummary = !!entry.summaryText
const [audioState, setAudioState] = useState<'idle' | 'loading' | 'playing' | 'unavailable'>('idle')
const audioRef = useRef<HTMLAudioElement | null>(null)
const objectUrlRef = useRef<string | null>(null)
const [audioState, setAudioState] = useState<HistoryAudioState>('idle')
const [tagFailure, setTagFailure] = useState<TagFailure | null>(null)
const playerRef = useRef<HistoryAudioPlayer | null>(null)
const stopAudio = useCallback((next: 'idle' | 'unavailable' = 'idle') => {
audioRef.current?.pause()
audioRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
setAudioState(next)
const getPlayer = useCallback((): HistoryAudioPlayer => {
if (!playerRef.current) playerRef.current = new HistoryAudioPlayer(browserHistoryAudioPort, setAudioState)
return playerRef.current
}, [])
useEffect(() => () => stopAudio(), [stopAudio])
// 언마운트되면 받아 오는 중인 요청까지 끊는다. StrictMode 재마운트를 위해 참조도 비운다.
useEffect(
() => () => {
playerRef.current?.dispose()
playerRef.current = null
},
[],
)
// 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다.
const handleToggleAudio = useCallback(
async (e: React.MouseEvent) => {
(e: React.MouseEvent) => {
e.stopPropagation()
const player = getPlayer()
if (audioState === 'playing') {
stopAudio()
player.stop()
return
}
setAudioState('loading')
try {
const result = await window.electronAPI.history.getAudio({ id: entry.id })
if (!result.success || !result.data) {
stopAudio('unavailable')
return
}
let src: string
if (result.data.kind === 'local') {
src = URL.createObjectURL(new Blob([result.data.bytes.slice()], { type: result.data.mimeType }))
objectUrlRef.current = src
} else {
src = result.data.url
}
const audio = new Audio(src)
audioRef.current = audio
audio.onended = () => stopAudio()
audio.onerror = () => stopAudio('unavailable')
await audio.play()
setAudioState('playing')
} catch {
stopAudio('unavailable')
}
void player.play(entry.id)
},
[audioState, entry.id, stopAudio],
[audioState, entry.id, getPlayer],
)
const loadTags = useCallback(async () => {
if (!showTags) return
const result = await window.electronAPI.memo.getTags(entry.id)
if (result.success) setTags(result.data)
}, [entry.id, showTags])
useEffect(() => {
loadTags()
}, [loadTags])
if (!showTags) return
let active = true
void loadEntryTags(window.electronAPI.memo, entry.id).then((loaded) => {
if (active && loaded) setTags(loaded)
})
return () => {
active = false
}
}, [entry.id, showTags])
const handleCopyClick = (e: React.MouseEvent) => {
e.stopPropagation()
@ -109,26 +118,41 @@ export function HistoryEntryCard({
}
const handleAddTag = useCallback(async () => {
const trimmed = tagInput.trim()
if (!trimmed) return
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
if (result.success) {
setTags((prev) => [...prev, result.data])
setTagInput('')
setShowTagInput(false)
const outcome = await addEntryTag(window.electronAPI.memo, entry.id, tagInput)
if (outcome.status === 'empty') return
if (outcome.status === 'failed') {
setTagFailure({ kind: 'add', tag: outcome.tag })
return
}
setTagFailure(null)
setTags((prev) => [...prev.filter((t) => t.tag !== outcome.tag.tag), outcome.tag])
setTagInput('')
setShowTagInput(false)
}, [entry.id, tagInput])
const handleRemoveTag = useCallback(
async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags((prev) => prev.filter((t) => t.tag !== tag))
const removed = await removeEntryTag(window.electronAPI.memo, entry.id, tag)
if (!removed) {
setTagFailure({ kind: 'remove', tag })
return
}
setTagFailure(null)
setTags((prev) => prev.filter((t) => t.tag !== tag))
},
[entry.id],
)
const handleRetryTag = useCallback(() => {
if (!tagFailure) return
if (tagFailure.kind === 'remove') {
void handleRemoveTag(tagFailure.tag)
return
}
setShowTagInput(true)
void handleAddTag()
}, [tagFailure, handleAddTag, handleRemoveTag])
const handleTagKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
@ -394,15 +418,19 @@ export function HistoryEntryCard({
<Box
component="input"
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTagInput(e.target.value)
if (tagFailure?.kind === 'add') setTagFailure(null)
}}
onKeyDown={handleTagKeyDown}
onBlur={() => {
if (!tagInput.trim()) setShowTagInput(false)
}}
autoFocus
placeholder={t('memo.tagPlaceholder')}
aria-invalid={tagFailure?.kind === 'add'}
sx={{
border: `1px solid ${d3roPalette.accent.main}`,
border: `1px solid ${tagFailure?.kind === 'add' ? d3roPalette.tag.red : d3roPalette.accent.main}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
@ -429,6 +457,16 @@ export function HistoryEntryCard({
<Tag size={11} />
</IconButton>
)}
{tagFailure && (
<TactileBadge
tone="error"
role="alert"
onClick={handleRetryTag}
sx={{ cursor: 'pointer' }}
>
{`#${tagFailure.tag} · ${t('common.retry')}`}
</TactileBadge>
)}
</Box>
)}
@ -459,12 +497,12 @@ export function HistoryEntryCard({
whiteSpace: 'pre-wrap',
}}
>
{summary.summaryText}
{summaryDisplayText(summary) || entry.summaryText || t('meetingSummary.noSummary')}
</Box>
</Box>
) : (
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.inactive }}>
{entry.summaryText || t('meetingSummary.empty')}
{entry.summaryText || t('meetingSummary.noSummary')}
</Box>
)}
</Box>

View file

@ -0,0 +1,177 @@
// src/renderer/components/shared/history-entry-card-actions.ts
// HistoryEntryCard의 IO 정책 — 메모 태그 계약, 요약 표시 텍스트, 녹음 재생 수명.
// 렌더러 전역(window.electronAPI, URL, Audio)에 직접 닿지 않고 포트를 주입받아
// 노드 테스트에서 그대로 검증할 수 있다. 어댑터는 컴포넌트(구성 루트)가 만든다.
import type { IPCResult } from '@d3ro/core/errors'
import type {
AddTagParams,
GetTagsParams,
HistoryAudioSource,
MeetingSummaryResult,
MemoTag,
RemoveTagParams,
} from '@d3ro/core/types'
// ── 메모 태그 ─────────────────────────────────────────────
/** preload `memo` API 중 카드가 쓰는 부분. 인자는 반드시 params 객체 하나다. */
export interface MemoTagPort {
getTags(params: GetTagsParams): Promise<IPCResult<MemoTag[]>>
addTag(params: AddTagParams): Promise<IPCResult<MemoTag>>
removeTag(params: RemoveTagParams): Promise<IPCResult<void>>
}
export type AddEntryTagOutcome =
| { status: 'empty' }
| { status: 'added'; tag: MemoTag }
| { status: 'failed'; tag: string }
/** 항목의 태그를 불러온다. 실패하면 null — 호출자가 기존 목록을 유지한다. */
export async function loadEntryTags(port: MemoTagPort, historyId: string): Promise<MemoTag[] | null> {
try {
const result = await port.getTags({ historyId })
return result.success ? result.data : null
} catch {
return null
}
}
/** 입력값을 다듬어 태그를 붙인다. 빈 입력은 호출하지 않는다. */
export async function addEntryTag(
port: MemoTagPort,
historyId: string,
rawTag: string,
): Promise<AddEntryTagOutcome> {
const tag = rawTag.trim()
if (!tag) return { status: 'empty' }
try {
const result = await port.addTag({ historyId, tag })
return result.success ? { status: 'added', tag: result.data } : { status: 'failed', tag }
} catch {
return { status: 'failed', tag }
}
}
/** 태그를 뗀다. 성공 여부만 돌려준다. */
export async function removeEntryTag(port: MemoTagPort, historyId: string, tag: string): Promise<boolean> {
try {
const result = await port.removeTag({ historyId, tag })
return result.success
} catch {
return false
}
}
// ── 회의 요약 ─────────────────────────────────────────────
/**
* 저장된 요약에서 보여 줄 본문. `summary`가 비었으면 `rawMarkdown`,
* 둘 다 비었으면 빈 문자열(호출자가 대체 문구를 고른다).
*/
export function summaryDisplayText(summary: Pick<MeetingSummaryResult, 'summary' | 'rawMarkdown'>): string {
const body = summary.summary?.trim()
if (body) return body
return summary.rawMarkdown?.trim() ?? ''
}
// ── 녹음 재생 ─────────────────────────────────────────────
export type HistoryAudioState = 'idle' | 'loading' | 'playing' | 'unavailable'
export interface PlayableAudio {
play(): Promise<void>
pause(): void
}
export interface HistoryAudioPort {
fetchAudio(historyId: string): Promise<IPCResult<HistoryAudioSource | null>>
createObjectUrl(bytes: Uint8Array, mimeType: string): string
revokeObjectUrl(url: string): void
createAudio(src: string, events: { onEnded(): void; onError(): void }): PlayableAudio
}
/**
* 카드 하나의 녹음 재생 수명을 소유한다. 요청마다 세대 번호를 올려,
* 오디오를 받아 오는 동안 멈춤·언마운트(dispose)가 일어나면 늦게 도착한 응답은
* 재생하지 않고 만든 object URL도 바로 해제한다.
*/
export class HistoryAudioPlayer {
private generation = 0
private audio: PlayableAudio | null = null
private objectUrl: string | null = null
private disposed = false
constructor(
private readonly port: HistoryAudioPort,
private readonly onState: (state: HistoryAudioState) => void,
) {}
async play(historyId: string): Promise<void> {
if (this.disposed) return
const gen = this.release()
this.emit('loading')
try {
const result = await this.port.fetchAudio(historyId)
if (!this.isCurrent(gen)) return
if (!result.success || !result.data) {
this.stop('unavailable')
return
}
const source = result.data
let src: string
if (source.kind === 'local') {
src = this.port.createObjectUrl(source.bytes, source.mimeType)
this.objectUrl = src
} else {
src = source.url
}
const audio = this.port.createAudio(src, {
onEnded: () => {
if (this.isCurrent(gen)) this.stop()
},
onError: () => {
if (this.isCurrent(gen)) this.stop('unavailable')
},
})
this.audio = audio
await audio.play()
// play()가 끝나기 전에 멈췄다면 release()가 이미 pause·revoke했다.
if (!this.isCurrent(gen)) return
this.emit('playing')
} catch {
if (this.isCurrent(gen)) this.stop('unavailable')
}
}
stop(next: 'idle' | 'unavailable' = 'idle'): void {
this.release()
this.emit(next)
}
/** 언마운트 시 호출. 재생 중이든 받아 오는 중이든 모두 끊고 이후 상태 알림을 막는다. */
dispose(): void {
this.disposed = true
this.release()
}
private isCurrent(gen: number): boolean {
return !this.disposed && gen === this.generation
}
/** 현재 오디오를 멈추고 URL을 해제한 뒤 새 세대 번호를 돌려준다. */
private release(): number {
this.generation += 1
this.audio?.pause()
this.audio = null
if (this.objectUrl) {
this.port.revokeObjectUrl(this.objectUrl)
this.objectUrl = null
}
return this.generation
}
private emit(state: HistoryAudioState): void {
if (!this.disposed) this.onState(state)
}
}

View file

@ -0,0 +1,187 @@
import fs from 'fs'
import path from 'path'
import { describe, expect, it, vi } from 'vitest'
import type { IPCResult } from '@d3ro/core/errors'
import type { HistoryAudioSource, MemoTag } from '@d3ro/core/types'
import {
HistoryAudioPlayer,
addEntryTag,
loadEntryTags,
removeEntryTag,
summaryDisplayText,
type HistoryAudioPort,
type HistoryAudioState,
type MemoTagPort,
} from '../../src/renderer/components/shared/history-entry-card-actions'
const CARD = path.resolve(__dirname, '../../src/renderer/components/shared/HistoryEntryCard.tsx')
function fail<T>(): IPCResult<T> {
return { success: false, error: { code: 'UNKNOWN', message: 'Failed' } } as unknown as IPCResult<T>
}
/** preload 계약을 흉내 낸다 — 인자 하나(params)만 main으로 넘긴다. */
function memoPort(tags: MemoTag[] = []) {
const seen: unknown[] = []
const port: MemoTagPort = {
getTags: vi.fn(async (...args: unknown[]) => {
const params = args[0] as { historyId?: unknown }
seen.push(params)
return typeof params?.historyId === 'string' ? { success: true as const, data: tags } : fail<MemoTag[]>()
}),
addTag: vi.fn(async (...args: unknown[]) => {
const params = args[0] as { historyId?: unknown; tag?: unknown }
seen.push(params)
if (typeof params?.historyId !== 'string' || typeof params?.tag !== 'string') return fail<MemoTag>()
return { success: true as const, data: { id: 't1', historyId: params.historyId, tag: params.tag } as MemoTag }
}),
removeTag: vi.fn(async (...args: unknown[]) => {
const params = args[0] as { historyId?: unknown; tag?: unknown }
seen.push(params)
if (typeof params?.historyId !== 'string' || typeof params?.tag !== 'string') return fail<void>()
return { success: true as const, data: undefined }
}),
}
return { port, seen }
}
describe('HistoryEntryCard — 메모 태그 계약', () => {
it('getTags/addTag/removeTag는 { historyId, tag } params 객체 하나로 부른다', async () => {
const existing = { id: 'x', historyId: 'h1', tag: 'work' } as MemoTag
const { port, seen } = memoPort([existing])
expect(await loadEntryTags(port, 'h1')).toEqual([existing])
const added = await addEntryTag(port, 'h1', ' meeting ')
expect(added).toEqual({ status: 'added', tag: { id: 't1', historyId: 'h1', tag: 'meeting' } })
expect(await removeEntryTag(port, 'h1', 'work')).toBe(true)
expect(seen).toEqual([{ historyId: 'h1' }, { historyId: 'h1', tag: 'meeting' }, { historyId: 'h1', tag: 'work' }])
})
it('빈 입력은 호출하지 않고, 실패·예외는 failed/false/null로 알린다', async () => {
const { port } = memoPort()
expect(await addEntryTag(port, 'h1', ' ')).toEqual({ status: 'empty' })
expect(port.addTag).not.toHaveBeenCalled()
const broken: MemoTagPort = {
getTags: async () => fail<MemoTag[]>(),
addTag: async () => {
throw new Error('ipc down')
},
removeTag: async () => fail<void>(),
}
expect(await loadEntryTags(broken, 'h1')).toBeNull()
expect(await addEntryTag(broken, 'h1', 'x')).toEqual({ status: 'failed', tag: 'x' })
expect(await removeEntryTag(broken, 'h1', 'x')).toBe(false)
})
it('카드는 memo API에 위치 인자를 넘기지 않는다', () => {
const src = fs.readFileSync(CARD, 'utf8')
expect(src).not.toMatch(/memo\.(getTags|addTag|removeTag)\(\s*entry\.id/)
expect(src).not.toMatch(/summary\.summaryText/)
})
})
describe('HistoryEntryCard — 요약 표시', () => {
it('MeetingSummaryResult.summary를 보여 주고, 비었으면 rawMarkdown', () => {
expect(summaryDisplayText({ summary: '결정: 예산 확정', rawMarkdown: '# md' })).toBe('결정: 예산 확정')
expect(summaryDisplayText({ summary: ' ', rawMarkdown: '# md' })).toBe('# md')
expect(summaryDisplayText({ summary: '', rawMarkdown: '' })).toBe('')
})
})
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
function audioHarness() {
const pending = deferred<IPCResult<HistoryAudioSource | null>>()
const played: string[] = []
const paused: string[] = []
const created: string[] = []
const revoked: string[] = []
const events: Array<{ onEnded(): void; onError(): void }> = []
const port: HistoryAudioPort = {
fetchAudio: () => pending.promise,
createObjectUrl: () => {
const url = `blob:${created.length}`
created.push(url)
return url
},
revokeObjectUrl: (url) => revoked.push(url),
createAudio: (src, ev) => {
events.push(ev)
return {
play: async () => {
played.push(src)
},
pause: () => paused.push(src),
}
},
}
const states: HistoryAudioState[] = []
const player = new HistoryAudioPlayer(port, (s) => states.push(s))
return { pending, player, played, paused, created, revoked, events, states }
}
const localAudio: IPCResult<HistoryAudioSource | null> = {
success: true,
data: { kind: 'local', bytes: new Uint8Array([1, 2]), mimeType: 'audio/webm' },
}
describe('HistoryEntryCard — 녹음 재생 수명', () => {
it('받아 오는 중에 언마운트(dispose)되면 늦은 응답을 재생하지 않는다', async () => {
const h = audioHarness()
const run = h.player.play('h1')
h.player.dispose()
h.pending.resolve({ success: true, data: { kind: 'remote', url: 'https://signed' } })
await run
expect(h.played).toEqual([])
expect(h.created).toEqual([])
expect(h.states).toEqual(['loading'])
})
it('받아 오는 중에 멈추면 로컬 녹음도 object URL을 만들지 않는다', async () => {
const h = audioHarness()
const run = h.player.play('h1')
h.player.stop()
h.pending.resolve(localAudio)
await run
expect(h.played).toEqual([])
expect(h.created).toEqual([])
expect(h.states).toEqual(['loading', 'idle'])
})
it('정상 재생 → 끝나면 URL 해제, 재생 중 dispose는 멈추고 해제한다', async () => {
const h = audioHarness()
h.pending.resolve(localAudio)
await h.player.play('h1')
expect(h.played).toEqual(['blob:0'])
expect(h.states).toEqual(['loading', 'playing'])
h.events[0].onEnded()
expect(h.revoked).toEqual(['blob:0'])
expect(h.states.at(-1)).toBe('idle')
const h2 = audioHarness()
h2.pending.resolve(localAudio)
await h2.player.play('h1')
h2.player.dispose()
expect(h2.paused).toEqual(['blob:0'])
expect(h2.revoked).toEqual(['blob:0'])
// 이전 세대 오디오의 늦은 onended는 상태를 건드리지 않는다
const before = h2.states.length
h2.events[0].onEnded()
expect(h2.states.length).toBe(before)
})
it('녹음이 없으면 unavailable', async () => {
const h = audioHarness()
h.pending.resolve({ success: true, data: null })
await h.player.play('h1')
expect(h.states).toEqual(['loading', 'unavailable'])
})
})