feat(desktop): honest, unified dictation popups with Esc to cancel

Wave 3 of the 2026-09-28 design overhaul.

- Esc (no modifiers) cancels an in-progress dictation, recording or
  processing: VoiceModeService.requestEscape() queues the dead 'escape'
  action. Clicking the capsule cancels only while recording, so a stray
  click during processing no longer throws the text away.
- Errors show in the user's language: bootstrap now passes error.code to
  the tip (the translated branch never ran), and one core classifier
  (voice-error-message) feeds both the tip and the main-window alert.
- Capsule: bars stay flat without input (no fake "alive" wobble before the
  mic opens), an indeterminate bar replaces the made-up percentage, the
  partial transcript stays visible while processing, an Esc hint, and a
  brief "Inserted" state so success is visible.
- All six popups share popups/_shared (Pretendard, one panel radius,
  selection, kbd hints, reduced motion). History/command popups get
  titles, listbox roles and window sizes from popup-list-geometry (empty
  states were clipped); the command popup's "0" key is registered;
  result popup explains why it appeared and can be closed; captions keep
  a theme-independent dark backing on purpose.
- Popup themes follow the OS when set to system and the document lang
  follows the app language.
- Site hero demo mirrors the new capsule (done state, indeterminate bar,
  Esc hint; 10 locales).
This commit is contained in:
Yun Chan 2026-09-28 20:46:24 +09:00
parent f064c93197
commit 7b1210b5d1
41 changed files with 1565 additions and 1143 deletions

View file

@ -15,6 +15,7 @@ interface FakeWebContents {
send: (channel: string, data: unknown) => void
isDestroyed: () => boolean
insertCSS: () => Promise<string>
executeJavaScript: () => Promise<unknown>
invalidate: () => void
setWindowOpenHandler: () => void
}
@ -56,6 +57,7 @@ vi.mock('electron', () => {
},
isDestroyed: () => false,
insertCSS: async () => '',
executeJavaScript: async () => undefined,
invalidate: () => undefined,
setWindowOpenHandler: () => undefined
}
@ -91,6 +93,8 @@ vi.mock('electron', () => {
const scaled = (value: number): number => Math.round(value / state.scale)
return {
// 팝업 테마: 시스템 테마 해석(2026-09-28)
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {

View file

@ -0,0 +1,282 @@
// redteam r3-4 회귀
// 1) 녹음 phase 전에 끝난 세션의 오류(STT 모델 미설치·쿼터·자막 사용 중)도 RecordingTip 을 띄워 보인다.
// 예전엔 showError 가 상태 IPC 만 보내 숨겨진 창에 그려졌고 사용자에겐 오류음만 남았다.
// 2) macOS 에서 애플리케이션 메뉴를 null 로 지우지 않는다(Cmd+C/V/X/A/Z/Q 는 role 메뉴로만 동작).
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { getI18n } from '@d3ro/i18n'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
type Listener = (...args: unknown[]) => void
interface FakeWindow {
options: Record<string, unknown>
visible: boolean
bounds: Array<{ x: number; y: number; width: number; height: number }>
sent: Array<{ channel: string; data: unknown }>
contentsListeners: Map<string, Listener[]>
showInactive: ReturnType<typeof vi.fn>
}
const state = vi.hoisted(() => ({
windows: [] as FakeWindow[],
setApplicationMenu: vi.fn(),
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
class FakeBrowserWindow {
options: Record<string, unknown>
visible = false
destroyed = false
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
sent: Array<{ channel: string; data: unknown }> = []
listeners = new Map<string, Listener>()
contentsListeners = new Map<string, Listener[]>()
webContents = {
on: (event: string, listener: Listener) => {
const list = this.contentsListeners.get(event) ?? []
list.push(listener)
this.contentsListeners.set(event, list)
},
send: (channel: string, data: unknown) => {
this.sent.push({ channel, data })
},
isDestroyed: () => false,
isLoading: () => false,
insertCSS: vi.fn(async () => ''),
executeJavaScript: async () => undefined,
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
openDevTools: vi.fn(),
}
showInactive = vi.fn(() => {
this.visible = true
})
constructor(options: Record<string, unknown>) {
this.options = options
state.windows.push(this as unknown as FakeWindow)
}
on(event: string, listener: Listener): this {
this.listeners.set(event, listener)
return this
}
once(event: string, listener: Listener): this {
return this.on(event, listener)
}
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.bounds.push(bounds)
}
isVisible(): boolean {
return this.visible
}
isDestroyed(): boolean {
return this.destroyed
}
hide(): void {
this.visible = false
}
show(): void {
this.visible = true
}
setAlwaysOnTop = vi.fn()
moveTop = vi.fn()
setSkipTaskbar = vi.fn()
setMenu = vi.fn()
center = vi.fn()
restore = vi.fn()
focus = vi.fn()
flashFrame = vi.fn()
isMinimized = vi.fn(() => false)
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
}
return {
// 팝업 테마: 시스템 테마 해석(2026-09-28)
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {
getPrimaryDisplay: vi.fn(() => ({
workAreaSize: { width: 1920, height: 1080 },
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
})),
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]),
getCursorScreenPoint: vi.fn(() => ({ x: 500, y: 500 })),
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
screenToDipRect: vi.fn((_win: unknown, rect: unknown) => rect),
},
ipcMain: { on: vi.fn(), once: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
Menu: {
setApplicationMenu: (...args: unknown[]) => state.setApplicationMenu(...args),
buildFromTemplate: (...args: unknown[]) => state.buildFromTemplate(...args),
},
clipboard: { writeText: vi.fn() },
shell: { openExternal: vi.fn(async () => undefined) },
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
Tray: vi.fn(),
dialog: { showErrorBox: vi.fn() },
}
})
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
const config: Record<string, unknown> = { theme: 'dark', language: 'ko', closeToTray: true }
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => config[key],
configSet: (key: string, value: unknown) => {
config[key] = value
},
}))
vi.mock('../../../src/main/lifecycle', () => ({
getIsQuitting: () => false,
setIsQuitting: () => undefined,
}))
const originalPlatform = process.platform
afterAll(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
beforeEach(() => {
vi.resetModules()
state.windows.length = 0
state.setApplicationMenu.mockClear()
state.buildFromTemplate.mockClear()
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
function tipStates(win: FakeWindow): Array<{ state?: string; errorMessage?: string }> {
for (const listener of win.contentsListeners.get('did-finish-load') ?? []) listener()
return win.sent
.filter((m) => m.channel === IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED)
.map((m) => m.data as { state?: string; errorMessage?: string })
}
describe('RecordingTip 오류 표시 — recording phase 전에 난 오류도 보인다', () => {
it('phase 없이 onError → 숨겨진 팁 창을 띄우고 오류 문구를 보낸다', async () => {
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
'../../../src/main/windows/recording-tip-presenter'
)
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
presenter.onSessionStarted()
presenter.onError('Cannot start dictation: caption mode active')
expect(state.windows).toHaveLength(1)
const tip = state.windows[0]
expect(tip.visible).toBe(true)
expect(tip.showInactive).toHaveBeenCalledTimes(1)
expect(tip.bounds).toHaveLength(1)
expect(tipStates(tip).at(-1)).toMatchObject({
state: 'error',
errorMessage: 'Cannot start dictation: caption mode active',
})
presenter.dispose()
})
it('STT 모델 미설치 코드는 사용자 언어 문구로 바꿔 그린다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
'../../../src/main/windows/recording-tip-presenter'
)
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
presenter.onError('STT model not installed: base', 'error', ErrorCode.STTModelNotFound)
const tip = wm.getRecordingTipWindow() as unknown as FakeWindow
expect(tip.visible).toBe(true)
expect(tipStates(tip).at(-1)).toMatchObject({
state: 'error',
errorMessage: getI18n('ko').t('voice.error.modelMissing'),
})
presenter.dispose()
})
it('녹음 도중 난 오류는 떠 있는 팁 자리를 옮기지 않는다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showRecordingTip('recording')
const tip = state.windows[0]
expect(tip.bounds).toHaveLength(1)
wm.showRecordingTipError('boom')
expect(tip.bounds).toHaveLength(1)
expect(tip.visible).toBe(true)
expect(tipStates(tip).map((m) => m.state)).toEqual(['recording', 'error'])
})
it('숨겨진 팁은 오류 때 다시 뜬다(이전 세션이 숨긴 뒤)', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showRecordingTip('recording')
wm.hideRecordingTip()
const tip = state.windows[0]
expect(tip.visible).toBe(false)
wm.showRecordingTipError('boom')
expect(tip.visible).toBe(true)
expect(tip.showInactive).toHaveBeenCalledTimes(2)
expect(tipStates(tip).at(-1)).toMatchObject({ state: 'error', errorMessage: 'boom' })
})
})
describe('resolveRecordingTipErrorMessage', () => {
it('코드별로 번역 문구를 고르고, 모르는 코드는 원문을 그린다', async () => {
const { resolveRecordingTipErrorMessage } = await import('../../../src/main/windows/recording-tip-error-message')
const { t } = getI18n('ko')
expect(resolveRecordingTipErrorMessage(ErrorCode.STTModelNotFound, 'raw', t)).toBe(t('voice.error.modelMissing'))
expect(resolveRecordingTipErrorMessage(ErrorCode.QuotaExceeded, 'raw', t)).toBe(
t('license.quotaExceeded.title', { feature: t('license.feature.dictation') }),
)
// 2026-09-28: 받아쓰기가 실제로 내보내는 코드(자막 충돌·마이크·무음·전사 실패·다듬기 실패)는 전부 사용자 문구로
expect(resolveRecordingTipErrorMessage(ErrorCode.CaptionAlreadyActive, 'raw', t)).toBe(t('voice.error.captionActive'))
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureStartFailed, 'raw', t)).toBe(t('voice.error.micOpen'))
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureFailed, 'raw', t)).toBe(t('voice.error.micLost'))
expect(resolveRecordingTipErrorMessage(ErrorCode.STTNoAudioData, 'raw', t)).toBe(t('voice.error.noSpeech'))
expect(resolveRecordingTipErrorMessage(ErrorCode.STTTranscriptionFailed, 'raw', t)).toBe(t('voice.error.transcription'))
expect(resolveRecordingTipErrorMessage(ErrorCode.LLMProcessingFailed, 'raw', t)).toBe(t('voice.error.llmFailed'))
// 분류 밖의 코드와 코드 없음은 원문
expect(resolveRecordingTipErrorMessage(ErrorCode.TTSVoiceNotFound, 'raw', t)).toBe('raw')
expect(resolveRecordingTipErrorMessage(undefined, 'raw', t)).toBe('raw')
})
})
describe('애플리케이션 메뉴 — macOS 는 편집/종료 단축키용 role 메뉴를 유지한다', () => {
it('darwin 에서 메인 창을 만들면 editMenu 가 든 메뉴를 설치한다(null 아님)', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const wm = await import('../../../src/main/windows/WindowManager')
wm.createMainWindow()
expect(state.setApplicationMenu).toHaveBeenCalledTimes(1)
const installed = state.setApplicationMenu.mock.calls[0][0] as { template: Array<{ role: string }> } | null
expect(installed).not.toBeNull()
expect(installed?.template.map((item) => item.role)).toEqual(['appMenu', 'editMenu', 'windowMenu'])
})
it('win32 에서는 지금처럼 메뉴를 제거한다(Alt 키 메뉴 활성화 방지)', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const wm = await import('../../../src/main/windows/WindowManager')
wm.createMainWindow()
expect(state.setApplicationMenu).toHaveBeenCalledWith(null)
expect(state.buildFromTemplate).not.toHaveBeenCalled()
})
it('buildApplicationMenuTemplate 는 darwin 만 템플릿을 준다', async () => {
const { buildApplicationMenuTemplate } = await import('../../../src/main/windows/app-menu')
expect(buildApplicationMenuTemplate('linux')).toBeNull()
expect(buildApplicationMenuTemplate('win32')).toBeNull()
expect(buildApplicationMenuTemplate('darwin')?.map((item) => item.role)).toEqual([
'appMenu',
'editMenu',
'windowMenu',
])
})
})

View file

@ -14,6 +14,7 @@ function fakeView(): RecordingTipView & { calls: string[] } {
showRecording: () => calls.push('show'),
hide: () => calls.push('hide'),
showThinking: () => calls.push('thinking'),
showDone: () => calls.push('done'),
showError: (message) => calls.push(`error:${message}`),
sendAudioLevel: (level) => calls.push(`level:${level}`),
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
@ -91,4 +92,24 @@ describe('RecordingTipPresenter', () => {
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:x'])
})
it('완료는 "입력함"을 잠깐 보이고 숨기며, 곧바로 시작한 새 세션의 팁은 숨기지 않는다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onSessionCompleted()
vi.advanceTimersByTime(699)
expect(view.calls).toEqual(['show', 'done'])
vi.advanceTimersByTime(1)
expect(view.calls).toEqual(['show', 'done', 'hide'])
const view2 = fakeView()
const presenter2 = new RecordingTipPresenter(view2)
presenter2.onSessionCompleted()
vi.advanceTimersByTime(300)
presenter2.onSessionStarted()
presenter2.onPhase('recording')
vi.advanceTimersByTime(2000)
expect(view2.calls).toEqual(['done', 'show'])
})
})