diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..da3624c60 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.
+- After analysis fails, customer-facing copy must enable the next rehearsal action (try this song again or choose another file). Do not leave a failed analysis as a message-only dead end.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..2b6545582 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.
+- After analysis fails, the workspace error card names try-this-song-again and choose-another-song as the next actions instead of a message-only dead end.
## Security source
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..28b7b9076 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- After analysis fails, the workspace names Try this song again and Choose another song as the next rehearsal actions.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..79eae7e43 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.
+After analysis fails, the workspace must name Try this song again or Choose another song rather than leaving a message-only error.
+
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index ed4f967bd..f9a5eabe5 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -641,7 +641,7 @@ fn select_local_audio_source(
let path = FileDialog::new()
.add_filter("Audio", &AUDIO_EXTENSIONS)
.pick_file()
- .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?;
+ .ok_or_else(|| "User cancelled".to_string())?;
let source = normalize_local_audio_source(&path)?;
let project_id = next_project_id(&state);
let project_root = app_owned_root(&app, "projects", &project_id)?;
diff --git a/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx
new file mode 100644
index 000000000..e8eacf331
--- /dev/null
+++ b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx
@@ -0,0 +1,87 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { App } from "./App";
+
+const analysisMocks = vi.hoisted(() => ({
+ selectLocalAudioSource: vi.fn(),
+ startAnalysisJob: vi.fn()
+}));
+
+vi.mock("./features/score/ScoreView", () => ({
+ ScoreView: () =>
Score view
+}));
+
+vi.mock("./lib/analysis", () => ({
+ MAX_YOUTUBE_URL_LENGTH: 2000,
+ createDefaultAnalysisRequest: () => ({
+ sourceKind: "demo",
+ sourceLabel: "Late Night Set",
+ roleFocus: ["bass-guitar", "keys-right", "lead-vocal"]
+ }),
+ getAnalysisJobStatus: vi.fn(),
+ importYoutubeUrl: vi.fn(),
+ isSupportedYoutubeUrl: () => false,
+ loadProject: vi.fn(),
+ saveProject: vi.fn(),
+ selectLocalAudioSource: analysisMocks.selectLocalAudioSource,
+ startAnalysisJob: analysisMocks.startAnalysisJob,
+ subscribeToAnalysisJobUpdates: vi.fn().mockResolvedValue(() => undefined)
+}));
+
+const admittedBootstrap = {
+ projectId: "project-1",
+ sourceMode: "reference",
+ projectRoot: "/tmp/bandscope/projects/project-1",
+ cacheRoot: "/tmp/bandscope/cache/project-1",
+ tempRoot: "/tmp/bandscope/temp/project-1",
+ source: {
+ sourcePath: "/Users/test/Music/late-night-set.wav",
+ fileName: "late-night-set.wav",
+ extension: "wav",
+ fileSizeBytes: 1024000
+ }
+};
+
+describe("analysis failure recovery cancellation", () => {
+ beforeEach(() => {
+ analysisMocks.selectLocalAudioSource.mockReset();
+ analysisMocks.startAnalysisJob.mockReset();
+ });
+
+ it("keeps the admitted song and recovery actions when the replacement picker is cancelled", async () => {
+ analysisMocks.selectLocalAudioSource
+ .mockResolvedValueOnce({ ok: true, bootstrap: admittedBootstrap })
+ .mockResolvedValueOnce({
+ ok: false,
+ error: { code: "invalid_request", message: "User cancelled" }
+ });
+ analysisMocks.startAnalysisJob.mockResolvedValue({
+ jobId: "job-1",
+ state: "failed",
+ requestedAt: "2026-08-22T00:00:00.000Z",
+ updatedAt: "2026-08-22T00:00:01.000Z",
+ error: {
+ code: "engine_unavailable",
+ message: "Analysis engine is unavailable."
+ }
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
+ await waitFor(() => expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));
+ await waitFor(() => expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(2));
+
+ expect(screen.queryByText(/user cancelled/i)).toBeNull();
+ expect(screen.queryByText(/choose a wav, mp3, flac, or m4a file/i)).toBeNull();
+ expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false);
+ expect(screen.getByRole("button", { name: /choose another song/i }).hasAttribute("disabled")).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..f74ea9219 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -1076,6 +1076,119 @@ describe("App", () => {
});
});
+ it("retries the admitted song from the analysis failure card", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockResolvedValueOnce(failedJobStatus("job-5", "Analysis queue is full. Please wait for a running job to finish."))
+ .mockResolvedValueOnce(succeededResult());
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /try this song again/i })).toBeTruthy();
+ });
+ expect(screen.getByText(/this song is still on this device/i)).toBeTruthy();
+ expect(screen.getByText(/analysis queue is full/i)).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /try this song again/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Section Roadmap/i)).toBeTruthy();
+ });
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("lets the player choose another song after analysis fails", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockResolvedValueOnce(failedJobStatus("job-7", "Analysis engine is unavailable."))
+ .mockResolvedValueOnce(bootstrapResponse({
+ projectId: "project-2",
+ source: {
+ sourcePath: "/Users/test/Music/next-song.wav",
+ fileName: "next-song.wav",
+ extension: "wav",
+ fileSizeBytes: 2048000
+ }
+ }));
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/next-song\.wav/i)).toBeTruthy();
+ });
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy();
+ });
+
+ it("keeps the admitted song when choosing another file fails after analysis failure", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockResolvedValueOnce(failedJobStatus("job-8", "Analysis engine is unavailable."));
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy();
+ });
+
+ mockLocalAudioSelectionResult = {
+ ok: false,
+ error: { code: "invalid_request", message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." }
+ };
+ fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
+ });
+ expect(screen.getAllByRole("alert").some((alert) => /analysis engine is unavailable/i.test(alert.textContent ?? ""))).toBe(true);
+ expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false);
+ });
+
+ it("does not offer analysis retry after a project save failure", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockResolvedValueOnce(succeededResult());
+ mockSaveProject.mockRejectedValueOnce(new Error("Disk full"));
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy();
+ });
+ fireEvent.click(screen.getByRole("button", { name: /save project/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/failed to save project: disk full/i)).toBeTruthy();
+ });
+ expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull();
+ });
+
it("renders the result immediately when start returns a succeeded job", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index f3d678454..11a8e46df 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -59,6 +59,7 @@ const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi;
const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi;
type RehearsalView = "workspace" | "score";
+type WorkspaceJobErrorKind = "analysis" | "project";
const NAV_ITEMS = [
{ labelKey: "navWorkspace", icon: Home, view: "workspace" },
@@ -255,6 +256,7 @@ export function App() {
const [jobResult, setJobResult] = useState(null);
const [jobResultBootstrap, setJobResultBootstrap] = useState(null);
const [jobError, setJobError] = useState(null);
+ const [jobErrorKind, setJobErrorKind] = useState(null);
const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined);
const [isStarting, setIsStarting] = useState(false);
const [selectedBootstrap, setSelectedBootstrap] = useState(null);
@@ -289,10 +291,12 @@ export function App() {
setJobResultBootstrap(activeAnalysisBootstrap);
setActiveAnalysisBootstrap(null);
setJobError(null);
+ setJobErrorKind(null);
}
if (nextStatus.state === "failed") {
setActiveAnalysisBootstrap(null);
setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart")));
+ setJobErrorKind("analysis");
}
}, [activeAnalysisBootstrap, t]);
@@ -361,7 +365,9 @@ export function App() {
return;
}
const fallbackMessage = t("analysisCouldNotStart");
+ setActiveAnalysisBootstrap(null);
setJobError(fallbackMessage);
+ setJobErrorKind("analysis");
setJobStatus({
...jobStatus,
state: "failed",
@@ -389,6 +395,7 @@ export function App() {
const handleStartAnalysis = async () => {
const submittedBootstrap = selectedBootstrap;
setJobError(null);
+ setJobErrorKind(null);
setJobResult(null);
setJobResultBootstrap(null);
setJobStatus(null);
@@ -408,6 +415,7 @@ export function App() {
setJobStatus(null);
setActiveAnalysisBootstrap(null);
setJobError(t("analysisCouldNotStart"));
+ setJobErrorKind("analysis");
} finally {
setIsStarting(false);
}
@@ -420,12 +428,23 @@ export function App() {
const selection = await selectLocalAudioSource();
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
+ setJobError(null);
+ setJobErrorKind(null);
+ setJobStatus(null);
+ setActiveAnalysisBootstrap(null);
+ return;
+ }
+
+ if (isUserCancellation(selection.error.message)) {
return;
}
- setSelectedBootstrap(null);
setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio")));
setSelectionErrorSource("local");
+ if (jobErrorKind === "analysis") {
+ return;
+ }
+ setSelectedBootstrap(null);
setJobStatus(null);
};
@@ -452,6 +471,10 @@ export function App() {
if (selection.ok) {
setSelectedBootstrap(selection.bootstrap);
setYoutubeUrl("");
+ setJobError(null);
+ setJobErrorKind(null);
+ setJobStatus(null);
+ setActiveAnalysisBootstrap(null);
} else {
setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed")));
setSelectionErrorSource("youtube");
@@ -477,12 +500,14 @@ export function App() {
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
+ setJobErrorKind(null);
setSelectedBootstrap(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
if (!isUserCancellation(e)) {
setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`);
+ setJobErrorKind("project");
}
}
};
@@ -494,6 +519,7 @@ export function App() {
} catch (e) {
if (!isUserCancellation(e)) {
setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`);
+ setJobErrorKind("project");
}
}
};
@@ -506,7 +532,16 @@ export function App() {
/** Documented. */
const renderWorkspaceState = () => {
if (jobError) {
- return ;
+ const analysisRecovery = jobErrorKind === "analysis";
+ return (
+ { void handleStartAnalysis(); } : undefined}
+ onChooseAnotherSong={analysisRecovery ? () => { void handleChooseLocalAudio(); } : undefined}
+ actionsDisabled={analysisInFlight || isStarting || isImporting}
+ />
+ );
}
if (analysisInFlight || isStarting) {
return ;
diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx
new file mode 100644
index 000000000..94b27b2ee
--- /dev/null
+++ b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx
@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from "@storybook/react-vite"
+
+import { ErrorState } from "./WorkspaceStates"
+
+const meta = {
+ title: "Workspace/Analysis failure recovery",
+ component: ErrorState,
+ parameters: { layout: "padded" },
+ args: {
+ error: "Analysis queue is full. Please wait for a running job to finish.",
+ canRetry: true,
+ onRetry: () => undefined,
+ onChooseAnotherSong: () => undefined,
+ actionsDisabled: false,
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+/** Analysis failures keep the admitted song actionable instead of ending at a message-only alert. */
+export const RecoverableAnalysisFailure: Story = {}
+
+/** Retry stays visibly unavailable until a song has been admitted, while replacement remains available. */
+export const RetryUnavailable: Story = {
+ args: {
+ error: "Analysis could not start.",
+ canRetry: false,
+ },
+}
+
+/** In-flight recovery prevents duplicate submissions without removing the customer's next actions. */
+export const RecoveryInFlight: Story = {
+ args: {
+ actionsDisabled: true,
+ },
+}
+
+/** Project persistence failures remain message-only and do not imply that re-analysis is the remedy. */
+export const ProjectFailure: Story = {
+ args: {
+ error: "Failed to save project: Disk full",
+ canRetry: false,
+ onRetry: undefined,
+ onChooseAnotherSong: undefined,
+ },
+}
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..4869a5d52
--- /dev/null
+++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx
@@ -0,0 +1,62 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates";
+
+describe("WorkspaceStates", () => {
+ it("renders the empty prompt that names choosing a song", () => {
+ render();
+ expect(screen.getByText(/ready to analyze/i)).toBeTruthy();
+ expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy();
+ });
+
+ it("renders a busy analysis status", () => {
+ render();
+ expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
+ expect(screen.getByText(/analyzing audio/i)).toBeTruthy();
+ });
+
+ it("keeps project failures as a message-only alert", () => {
+ render();
+ expect(screen.getByRole("alert").textContent).toMatch(/an error occurred during analysis/i);
+ expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull();
+ });
+
+ it("names retry and choose-another as the next analysis actions", () => {
+ const onRetry = vi.fn();
+ const onChooseAnotherSong = vi.fn();
+ render(
+
+ );
+
+ expect(screen.getByRole("heading", { name: /analysis didn't finish/i })).toBeTruthy();
+ expect(screen.getByText(/this song is still on this device/i)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /try this song again/i }));
+ fireEvent.click(screen.getByRole("button", { name: /choose another song/i }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ expect(onChooseAnotherSong).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables retry until a song is admitted", () => {
+ const onRetry = vi.fn();
+ render(
+ undefined}
+ />
+ );
+
+ const retry = screen.getByRole("button", { name: /try this song again/i });
+ expect(retry).toBeDisabled();
+ expect(retry).toHaveAttribute("title", "Choose a song first");
+ fireEvent.click(retry);
+ expect(onRetry).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx
index 8f9aba1b1..8d8750701 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, Play, Upload } from "lucide-react";
/** Documented. */
export function EmptyState() {
@@ -38,17 +39,69 @@ export function LoadingState() {
);
}
-/** Documented. */
-export function ErrorState({ error }: { error?: string }) {
+/** Next-action handlers after analysis fails without claiming a demo or stem player. */
+export interface ErrorStateProps {
+ error?: string;
+ canRetry?: boolean;
+ onRetry?: () => void;
+ onChooseAnotherSong?: () => void;
+ actionsDisabled?: boolean;
+}
+
+/** Name the next rehearsal action after analysis fails. */
+export function ErrorState({
+ error,
+ canRetry = false,
+ onRetry,
+ onChooseAnotherSong,
+ actionsDisabled = false
+}: ErrorStateProps) {
const t = createTranslator(detectPreferredLocale());
+ const showRecoveryActions = Boolean(onRetry || onChooseAnotherSong);
+
return (