diff --git a/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx b/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx index ab090e8..f5d4599 100644 --- a/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx +++ b/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx @@ -5,6 +5,7 @@ import { useState, useRef, useCallback } from 'react' import { Box, TextField, Tooltip, Chip } from '@mui/material' import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { isImeComposingEvent } from '../../utils/keyboard' // Phase 15.5: 화자별 색상 매핑 (d3roPalette SSOT) const SPEAKER_COLORS = [ @@ -69,6 +70,7 @@ export function EditableSegment({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleCommit() diff --git a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx index b246e7e..de2f485 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx @@ -9,6 +9,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' 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 { useI18n } from '@d3ro/i18n' import type { MeetingChatMessage } from '@d3ro/core/types' @@ -110,6 +111,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 5bf20b0..dc9cf35 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -19,6 +19,7 @@ import { DocumentTab } from './DocumentTab' import { AddDocumentDialog } from './AddDocumentDialog' import { MeetingChatPanel } from './MeetingChatPanel' import { PhosphorText } from '@d3ro/ui/components/ds' +import { isImeComposingEvent } from '../../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { @@ -239,7 +240,10 @@ export function MeetingDetailTabs({ size="small" value={titleDraft} onChange={(e) => setTitleDraft(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()} + onKeyDown={(e) => { + if (isImeComposingEvent(e)) return + if (e.key === 'Enter') handleSaveTitle() + }} sx={{ flex: 1, '& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size }, diff --git a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx index af32033..ddf276b 100644 --- a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx +++ b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx @@ -14,6 +14,7 @@ import { MetalCard, Led } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { formatDuration } from '../../utils/formatters' +import { isImeComposingEvent } from '../../utils/keyboard' import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types' interface HistoryEntryCardProps { @@ -62,6 +63,7 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on }, [entry.id]) const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') } }, [handleAddTag]) diff --git a/apps/desktop/src/renderer/pages/CommandsPage.tsx b/apps/desktop/src/renderer/pages/CommandsPage.tsx index 615e997..82b15f9 100644 --- a/apps/desktop/src/renderer/pages/CommandsPage.tsx +++ b/apps/desktop/src/renderer/pages/CommandsPage.tsx @@ -12,6 +12,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow' import LinkIcon from '@mui/icons-material/Link' import { MetalCard, PhosphorText, Led, ScreenPanel } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' +import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { TemplateSection } from '../components/TemplateSection' @@ -334,6 +335,7 @@ function VoiceKeywordsSection({ instructions }: { instructions: CustomInstructio value={keywordInput} onChange={(e: React.ChangeEvent) => setKeywordInput(e.target.value)} onKeyDown={(e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) } if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') } }} diff --git a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx index 5a20efd..3bf904b 100644 --- a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx +++ b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx @@ -12,6 +12,7 @@ import SendIcon from '@mui/icons-material/Send' import DescriptionIcon from '@mui/icons-material/Description' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' +import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types' @@ -82,6 +83,7 @@ export function KnowledgeBasePage(): React.ReactElement { }, [query]) const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleQuery() diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index 0444d2a..7a846f0 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -16,6 +16,7 @@ import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' import { EditableSegment } from '../components/meeting/EditableSegment' import { PageHeader, EmptyStateCard } from '../components/shared' +import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { @@ -163,9 +164,7 @@ export function MeetingModePage(): React.ReactElement { const handleMemoKeyDown = useCallback( (e: React.KeyboardEvent) => { - // 한글 IME 조합 중 Enter는 keydown을 2번 발화(조합 확정 + 실제 Enter)하므로 - // isComposing=true인 이벤트는 무시해 addMemo 중복 호출 방지 - if (e.nativeEvent.isComposing || e.keyCode === 229) return + if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleAddMemo() diff --git a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx index ddb41f0..c30d0e9 100644 --- a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx +++ b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx @@ -11,6 +11,7 @@ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep' import CancelIcon from '@mui/icons-material/Cancel' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds' import { PageHeader } from '../components/shared' +import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { @@ -111,6 +112,7 @@ export function VoiceConversationPage(): React.ReactElement { }, []) const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSendText() diff --git a/apps/desktop/src/renderer/utils/keyboard.ts b/apps/desktop/src/renderer/utils/keyboard.ts new file mode 100644 index 0000000..3a26315 --- /dev/null +++ b/apps/desktop/src/renderer/utils/keyboard.ts @@ -0,0 +1,25 @@ +import type { KeyboardEvent } from 'react' + +/** + * 한글/일본어/중국어 IME 조합 중인 키 이벤트인지 검사한다. + * + * 한글 IME가 조합 중인 상태에서 Enter를 치면 Chromium이 `keydown`을 + * 두 번 발화한다: + * 1) IME 조합 확정 이벤트 — `nativeEvent.isComposing === true` + * (일부 플랫폼에선 `isComposing`이 이미 false로 내려온 채 `keyCode === 229`) + * 2) 실제 Enter 액션 — `isComposing === false` + * + * Enter 핸들러에서 이 함수가 `true`인 이벤트를 조기 return 하지 않으면 + * 같은 입력이 두 번 처리되어 IPC/DB 쓰기가 중복된다 (Bug 10 재현). + * + * 패턴: + * ```tsx + * const handleKeyDown = (e: KeyboardEvent) => { + * if (isImeComposingEvent(e)) return + * if (e.key === 'Enter' && !e.shiftKey) { ... } + * } + * ``` + */ +export function isImeComposingEvent(e: KeyboardEvent): boolean { + return e.nativeEvent.isComposing || e.keyCode === 229 +} diff --git a/memory/project_status.md b/memory/project_status.md index bc6a148..d8c5da4 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,48 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [12] /simplify 패스 — IME 가드 helper 추출 + 8곳 일괄 적용 (Phase 5 Part 5, 2026-04-11) + +> **Bug 10의 한글 IME Enter 중복 가드를 `isImeComposingEvent` helper로 추출**. MeetingModePage 기존 인라인 가드 교체 + 7개 사이트 신규 적용 → 총 8곳 통합. Phase 3.3 훅 8곳은 품질 리뷰 결과 이미 최적 상태로 판정(수정 없음). + +**신규 helper** — `apps/desktop/src/renderer/utils/keyboard.ts` +```ts +export function isImeComposingEvent(e: KeyboardEvent): boolean { + return e.nativeEvent.isComposing || e.keyCode === 229 +} +``` +JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함. + +**적용 8곳 (`isImeComposingEvent(e) return` 1줄 삽입)** +| 파일 | 라인 | 용도 | 변경 전 | +|---|---|---|---| +| `pages/MeetingModePage.tsx` | 164 | 회의 메모 입력 | 인라인 가드 4줄 (Bug 10 fix) | +| `pages/KnowledgeBasePage.tsx` | 84 | RAG 쿼리 | 가드 없음 | +| `pages/VoiceConversationPage.tsx` | 113 | 텍스트 채팅 | 가드 없음 | +| `components/meeting/MeetingChatPanel.tsx` | 111 | 미팅 챗 | 가드 없음 | +| `components/meeting/EditableSegment.tsx` | 70 | 전사 편집 (Enter+Esc) | 가드 없음 | +| `components/meeting/MeetingDetailTabs.tsx` | 242 | 미팅 타이틀 (인라인 arrow) | 인라인 `onKeyDown={(e)=>e.key==='Enter'&&...}` 1줄 → 블록 확장 | +| `components/shared/HistoryEntryCard.tsx` | 64 | 태그 추가 (Enter+Esc) | 가드 없음 | +| `pages/CommandsPage.tsx` | 336 | 키워드 추가 (Enter+Esc) | 가드 없음 | + +**Phase 3.3 훅 품질 리뷰 결론 — 수정 없음** +- `HistoryService.create/updateTitle`, `DictionaryService.add/update`, `MeetingModeService.startRecording/addMemo/_runPostProcessing/generateDocument`, `MeetingSummaryService.generateAndSave` 8곳 전부 `void getCloudSyncService().pushOne(table, id)` + `// Phase 3.3: fire-and-forget` 주석 일관. +- `CloudSyncService.pushOne` 내부가 try-catch로 warn만 찍고 삼킴 → 호출자는 무조건 안전, 로컬 write 차단 금지 철학 준수. +- helper 추출 후보 없음(이미 1줄). 일관성 OK, DRY OK, 에러 흡수 OK → **현 상태가 최적, 리팩터 불필요**. + +**검증** +- desktop `tsc --noEmit` ✅ EXIT=0 +- Vite HMR 자동 반영(기존 dev 프로세스 살아있음) — 별도 재기동 없이 적용 +- 대표 2곳 한글 Enter 실측 (VoiceConversationPage 채팅 / MeetingDetailTabs 타이틀) — 사용자 수동 검증 대기 + +**U5 해소, 남은 미해결 이슈** +- U1 — Realtime TIMED_OUT (블로커 아님) +- U3 — Refresh token 간헐적 소진 (블로커 아님) +- U4 — `package-lock.json` optional dep 정리 +- Phase 3.2 — PREMIUM_LLM quota 게이트 (Anthropic/OpenAI key 주입 대기) + +--- + ### SaaS [11] Meeting pre-push 직접 실증 + IME Enter 중복 addMemo 픽스 (Phase 5 Part 4, 2026-04-11) > **U2 해소.** Meeting 모드 녹음을 직접 돌려 Fix 1(startRecording pre-push) + addMemo hook을 실시간 로그로 증명. 부수적으로 발견된 한글 IME Enter 중복 addMemo 버그(Bug 10)를 `isComposing` 가드로 픽스.