From dd494bfe0d500d94354ccc636bdf7eea49056f28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:21:29 -0700 Subject: [PATCH 01/26] test(workspace): name Choose another song after local intake fails --- .../workspace/WorkspaceStates.test.tsx | 76 +++++++++++++++++++ .../src/lib/analysis.selection.test.ts | 73 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx create mode 100644 apps/desktop/src/lib/analysis.selection.test.ts diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..193dc61f2 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +describe("WorkspaceStates local selection failure", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("names choosing another song as the next rehearsal action", () => { + const onAction = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Choose another song" })); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy(); + expect(screen.getByText(/keeps the file local/i)).toBeTruthy(); + }); + + it("disables the next action while intake is already running", () => { + const onAction = vi.fn(); + render( + + ); + + expect(screen.getByRole("button", { name: "Choose another song" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Choose another song" })); + expect(onAction).not.toHaveBeenCalled(); + }); + + it("keeps analysis failures message-only when no recovery action is provided", () => { + render(); + + expect(screen.getByRole("heading", { name: "An error occurred during analysis. Please try again." })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull(); + }); + + it("localizes empty, loading, and selection-failure titles", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "그 파일로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 곡 선택하기" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/lib/analysis.selection.test.ts b/apps/desktop/src/lib/analysis.selection.test.ts new file mode 100644 index 000000000..c3239708a --- /dev/null +++ b/apps/desktop/src/lib/analysis.selection.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { selectLocalAudioSource } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +/** + * Security Notes: + * - Untrusted input: native picker rejection text, including path- and secret-shaped diagnostics. + * - Trust boundary: Tauri `select_local_audio_source` → this bridge → buyer-visible copy. + * - Safe failure: cancellation is silent; unknown native text is replaced with supported-format guidance. + * - Privacy: absolute paths and secret-shaped messages must not cross into LocalAudioSelectionResult.error. + */ +describe("selectLocalAudioSource cancellation and redaction", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("treats a native User cancelled Error as a silent cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("User cancelled")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ ok: false, cancelled: true }); + }); + + it("treats a native User cancelled string as a silent cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("User cancelled"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ ok: false, cancelled: true }); + }); + + it("preserves allowlisted file-read failure copy", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("Could not read the selected audio file.")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Could not read the selected audio file." + } + }); + }); + + it("redacts path-shaped native diagnostics before they become buyer copy", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue( + new Error("open failed: /Users/test/Music/secret-token.wav") + ); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + + it("redacts a non-Error native rejection that is not cancellation", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("open failed: /Users/test/Music/secret-token.wav"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); +}); From 8e7f6325bb21b43d2902911a7aad432edfce1b39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:22:20 -0700 Subject: [PATCH 02/26] feat(workspace): name Choose another song after local intake fails --- .../features/workspace/WorkspaceStates.tsx | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..e26c3dab0 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Upload } from "lucide-react"; /** Documented. */ export function EmptyState() { @@ -38,8 +39,25 @@ export function LoadingState() { ); } -/** Documented. */ -export function ErrorState({ error }: { error?: string }) { +/** Recovery action for a workspace error that still has a next rehearsal step. */ +export interface ErrorStateProps { + error?: string; + title?: string; + guidance?: string; + actionLabel?: string; + onAction?: () => void; + actionDisabled?: boolean; +} + +/** Render a workspace failure and name the next rehearsal action when one exists. */ +export function ErrorState({ + error, + title, + guidance, + actionLabel, + onAction, + actionDisabled = false +}: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); return ( @@ -47,8 +65,21 @@ export function ErrorState({ error }: { error?: string }) {
-

{t("workspaceErrorState")}

+

{title ?? t("workspaceErrorState")}

{error &&

{error}

} + {guidance &&

{guidance}

} + {onAction && actionLabel && ( + + )}
); From 35b0402aeffd91cd3e06d938f74907529cbc877e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:26:15 -0700 Subject: [PATCH 03/26] feat(desktop): treat local picker cancellation as a silent result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinguish User cancelled from unsupported-format failures so the workspace can keep the prior admitted song and recovery card. Security Notes: - Untrusted input: native picker rejection text, including path- and secret-shaped diagnostics. - Trust boundary: Tauri select_local_audio_source → this bridge → buyer-visible copy. - Safe failure: cancellation is silent; unknown native text is replaced with supported-format guidance. - Privacy: absolute paths and secret-shaped messages must not cross into LocalAudioSelectionResult.error. --- apps/desktop/src/lib/analysis.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..213acadb8 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -35,6 +35,7 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const LOCAL_AUDIO_USER_CANCELLED_MESSAGE = "User cancelled"; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", @@ -50,6 +51,7 @@ export { MAX_YOUTUBE_URL_LENGTH }; /** Documented. */ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } + | { ok: false; cancelled: true } | { ok: false; error: AnalysisJobError }; /** Documented. */ @@ -222,6 +224,17 @@ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); } +/** Documented. */ +function localAudioCancellationMessage(error: unknown): string | null { + if (typeof error === "string") { + return error.trim() === LOCAL_AUDIO_USER_CANCELLED_MESSAGE ? error.trim() : null; + } + if (error instanceof Error && error.message.trim() === LOCAL_AUDIO_USER_CANCELLED_MESSAGE) { + return error.message.trim(); + } + return null; +} + /** Documented. */ export async function selectLocalAudioSource(): Promise { try { @@ -231,6 +244,9 @@ export async function selectLocalAudioSource(): Promise Date: Sat, 22 Aug 2026 02:26:51 -0700 Subject: [PATCH 04/26] feat(i18n): name Choose another song after local intake fails --- apps/desktop/src/locales/en/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..a613a5743 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -11,6 +11,9 @@ "selectedAudio": "Selected audio", "sourceModeReference": "References the original file", "unsupportedLocalAudio": "Choose a WAV, MP3, FLAC, or M4A file to start analysis.", + "localSelectionFailureTitle": "That file can't start tonight", + "localSelectionFailureGuidance": "Choose a WAV, MP3, FLAC, or M4A file on this device. BandScope keeps the file local.", + "chooseAnotherSong": "Choose another song", "sectionConfidence": "Section confidence", "roleConfidence": "confidence", "harmonySource": "harmony source", From ea909db312c2073c8ea06c15b524bc58d6575177 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:28:19 -0700 Subject: [PATCH 05/26] feat(i18n): name Choose another song after local intake fails (ko) --- apps/desktop/src/locales/ko/common.json | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..017afe8a5 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -11,6 +11,9 @@ "selectedAudio": "선택한 오디오", "sourceModeReference": "원본 파일을 참조합니다", "unsupportedLocalAudio": "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요.", + "localSelectionFailureTitle": "그 파일로는 오늘 합주를 시작할 수 없습니다", + "localSelectionFailureGuidance": "이 기기에서 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요. 파일은 기기에만 남습니다.", + "chooseAnotherSong": "다른 곡 선택하기", "sectionConfidence": "구간 신뢰도", "roleConfidence": "신뢰도", "harmonySource": "화성 출처", @@ -54,7 +57,7 @@ "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", "sectionChordLabel": "코드", - "sectionCueLabel": "큐", + "sectionCueLabel": "큑", "priorityLabel": "우선순위", "chordEditAriaLabel": "{roleName}의 {sectionLabel} 코드 수정, 현재 {chord}", "chordEditPrompt": "새 코드 입력:", @@ -93,7 +96,7 @@ "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", - "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", + "brandMarkAriaLabel": "BandScope 원형 이퀸라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", "navImport": "가져오기", @@ -101,7 +104,7 @@ "navSections": "구간", "navRoles": "역할", "navStemLab": "스템 랩", - "navCues": "큐", + "navCues": "큑", "navTranspose": "전조", "navNotes": "노트", "primaryRehearsalViewsAriaLabel": "주요 합주 보기", @@ -111,13 +114,13 @@ "settingsComingSoon": "설정은 곧 제공됩니다", "helpComingSoon": "도움말은 곧 제공됩니다", "localFirst": "로컬 우선", - "localFirstDetail": "합주 지도는 이 기기에 머뭅니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", + "localFirstDetail": "합주 지도는 이 기기에 머묵니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", "sourceControlsAriaLabel": "소스 컨트롤", "statusReadyRehearsal": "준비됨 • 합주", "statusSyncedLocal": "동기화됨 • 로컬", "rehearsalConsoleTitle": "합주 콘솔", "workspaceHomeTitle": "작업 공간 홈", - "workspaceHomeSummary": "곡을 실전 합주 보기로 바꿉니다.", + "workspaceHomeSummary": "곡을 실전 합주 보기로 바꿁니다.", "youtubeUrlAriaLabel": "유튜브 URL", "clearYoutubeUrl": "유튜브 URL 지우기", "openProject": "프로젝트 열기", From d23399160b0db10b0ab7be8ec85ea0c70b9bc2d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:31:06 -0700 Subject: [PATCH 06/26] fix(i18n): restore exact Korean locale bytes after intake copy From eddfdb987d458c8624870858c1509e294cd50ca5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:33:42 -0700 Subject: [PATCH 07/26] fix(i18n): restore exact Korean locale syllables --- apps/desktop/src/locales/ko/common.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 017afe8a5..30e9972ad 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -57,7 +57,7 @@ "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", "sectionChordLabel": "코드", - "sectionCueLabel": "큑", + "sectionCueLabel": "큐", "priorityLabel": "우선순위", "chordEditAriaLabel": "{roleName}의 {sectionLabel} 코드 수정, 현재 {chord}", "chordEditPrompt": "새 코드 입력:", @@ -96,7 +96,7 @@ "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", - "brandMarkAriaLabel": "BandScope 원형 이퀸라이저 마크", + "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", "navImport": "가져오기", @@ -104,7 +104,7 @@ "navSections": "구간", "navRoles": "역할", "navStemLab": "스템 랩", - "navCues": "큑", + "navCues": "큐", "navTranspose": "전조", "navNotes": "노트", "primaryRehearsalViewsAriaLabel": "주요 합주 보기", @@ -114,13 +114,13 @@ "settingsComingSoon": "설정은 곧 제공됩니다", "helpComingSoon": "도움말은 곧 제공됩니다", "localFirst": "로컬 우선", - "localFirstDetail": "합주 지도는 이 기기에 머묵니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", + "localFirstDetail": "합주 지도는 이 기기에 머뭅니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", "sourceControlsAriaLabel": "소스 컨트롤", "statusReadyRehearsal": "준비됨 • 합주", "statusSyncedLocal": "동기화됨 • 로컬", "rehearsalConsoleTitle": "합주 콘솔", "workspaceHomeTitle": "작업 공간 홈", - "workspaceHomeSummary": "곡을 실전 합주 보기로 바꿁니다.", + "workspaceHomeSummary": "곡을 실전 합주 보기로 바꿉니다.", "youtubeUrlAriaLabel": "유튜브 URL", "clearYoutubeUrl": "유튜브 URL 지우기", "openProject": "프로젝트 열기", From c2bc7a6493074d958721108ac400f725e0a8eb5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:34:38 -0700 Subject: [PATCH 08/26] docs(agents): name Choose another song after local intake fails --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..33ff9a7c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- When local audio intake fails, customer-facing copy must name Choose another song as the next rehearsal action. Picker cancellation is silent and must not look like an unsupported-format error. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. From 9e00818f5adf7b93caa6984cee3fe375388ba63b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:35:11 -0700 Subject: [PATCH 09/26] docs(architecture): keep local picker cancellation silent --- ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..a12972588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Local-audio picker cancellation stays silent. Unsupported or unreadable local files name Choose another song as the next action and do not invent a bundled demo. ## Security source From 71f03d178d5b0657f44775165c45faf325af8b2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:35:29 -0700 Subject: [PATCH 10/26] docs(claude): name Choose another song after local intake fails --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..4586e39c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +When local audio intake fails, the workspace must name Choose another song as the next action. Picker cancellation is silent. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands From 56351064b801511fc56910f3b9e728ccdf539fd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:36:14 -0700 Subject: [PATCH 11/26] docs(changelog): name Choose another song after local intake fails --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..692bd7172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- When a local song cannot be admitted, the workspace names Choose another song as the next rehearsal action and keeps picker cancellation silent. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. From 5a10bf891b63afb8c049b90b8dab41fa1eea60bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:37:15 +0000 Subject: [PATCH 12/26] feat(workspace): name Choose another song after local intake fails Keep picker cancellation silent, recover with Choose another song, and disable the header action while the native picker is open. Security Notes: - Untrusted input: native picker rejection text. - Trust boundary: selectLocalAudioSource result -> workspace ErrorState. - Safe failure: cancellation keeps prior admitted song and recovery card. - Privacy: path-shaped native text is already redacted by the analysis bridge. --- apps/desktop/src/App.tsx | 52 ++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..a2d9fd488 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -263,6 +263,7 @@ export function App() { const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); + const [isChoosingLocalAudio, setIsChoosingLocalAudio] = useState(false); const [activeView, setActiveView] = useState("workspace"); const activeJobIdRef = useRef(null); const youtubeInputRef = useRef(null); @@ -415,18 +416,31 @@ export function App() { /** Documented. */ const handleChooseLocalAudio = async () => { - setSelectionError(null); - setSelectionErrorSource(null); - const selection = await selectLocalAudioSource(); - if (selection.ok) { - setSelectedBootstrap(selection.bootstrap); + if (isChoosingLocalAudio) { return; } - setSelectedBootstrap(null); - setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); - setSelectionErrorSource("local"); - setJobStatus(null); + setIsChoosingLocalAudio(true); + try { + const selection = await selectLocalAudioSource(); + if (selection.ok) { + setSelectionError(null); + setSelectionErrorSource(null); + setSelectedBootstrap(selection.bootstrap); + return; + } + + if ("cancelled" in selection) { + return; + } + + setSelectedBootstrap(null); + setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); + setSelectionErrorSource("local"); + setJobStatus(null); + } finally { + setIsChoosingLocalAudio(false); + } }; /** Documented. */ @@ -514,6 +528,20 @@ export function App() { if (jobResult) { return ; } + if (selectionError && selectionErrorSource === "local") { + return ( + { + void handleChooseLocalAudio(); + }} + actionDisabled={isChoosingLocalAudio} + /> + ); + } return ; }; @@ -682,7 +710,7 @@ export function App() {