Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dd494bf
test(workspace): name Choose another song after local intake fails
seonghobae Aug 22, 2026
8e7f632
feat(workspace): name Choose another song after local intake fails
seonghobae Aug 22, 2026
35b0402
feat(desktop): treat local picker cancellation as a silent result
seonghobae Aug 22, 2026
63e3618
feat(i18n): name Choose another song after local intake fails
seonghobae Aug 22, 2026
ea909db
feat(i18n): name Choose another song after local intake fails (ko)
seonghobae Aug 22, 2026
d233991
fix(i18n): restore exact Korean locale bytes after intake copy
seonghobae Aug 22, 2026
eddfdb9
fix(i18n): restore exact Korean locale syllables
seonghobae Aug 22, 2026
c2bc7a6
docs(agents): name Choose another song after local intake fails
seonghobae Aug 22, 2026
9e00818
docs(architecture): keep local picker cancellation silent
seonghobae Aug 22, 2026
71f03d1
docs(claude): name Choose another song after local intake fails
seonghobae Aug 22, 2026
5635106
docs(changelog): name Choose another song after local intake fails
seonghobae Aug 22, 2026
5a10bf8
feat(workspace): name Choose another song after local intake fails
seonghobae Aug 22, 2026
12432cb
test(desktop): cover local selection-failure next action and silent c…
seonghobae Aug 22, 2026
72a7e75
test(workspace): keep picker cancellation local to local intake
seonghobae Aug 22, 2026
6248b6d
fix(workspace): keep cancellation out of YouTube result contract
seonghobae Aug 22, 2026
946cf3e
test(workspace): expose replacement intake failure over loaded result
seonghobae Aug 22, 2026
6a1fbfc
fix(workspace): surface local intake recovery over loaded result
seonghobae Aug 22, 2026
473f5e8
test(workspace): pin native picker cancellation signal
seonghobae Aug 22, 2026
c6cea68
fix(workspace): make native picker cancellation distinct
seonghobae Aug 22, 2026
8ac58fe
test(workspace): resolve native contract from workspace cwd
seonghobae Aug 22, 2026
a101f02
fix(workspace): make local recovery guidance action-specific
seonghobae Aug 22, 2026
825c43a
fix(workspace): localize action-specific recovery guidance
seonghobae Aug 22, 2026
cfb8949
fix(workspace): preserve command-only invoke arity
seonghobae Aug 22, 2026
8b46f75
test(workspace): expose stale local recovery state
seonghobae Aug 22, 2026
9059b4c
fix(workspace): clear stale source errors on source changes
seonghobae Aug 22, 2026
75defc9
Merge branch 'develop' into feat/local-selection-failure-next-action
seonghobae Aug 25, 2026
f16f368
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
bd0fffd
fix(app): surface local intake failures from Score view; test shipped…
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
102 changes: 102 additions & 0 deletions apps/desktop/src/App.localSelectionFailureVisibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createDemoRehearsalSong } from "@bandscope/shared-types";
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(() => ({
getAnalysisJobStatus: vi.fn(),
importYoutubeUrl: vi.fn(),
isSupportedYoutubeUrl: vi.fn(() => false),
loadProject: vi.fn(),
saveProject: vi.fn(),
selectLocalAudioSource: vi.fn(),
startAnalysisJob: vi.fn(),
subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined)
}));

vi.mock("./lib/analysis", () => ({
createDefaultAnalysisRequest: () => ({
sourceKind: "demo",
sourceLabel: "Late Night Set",
roleFocus: ["bass-guitar", "keys-right", "lead-vocal"]
}),
getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus,
importYoutubeUrl: analysisMocks.importYoutubeUrl,
isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl,
loadProject: analysisMocks.loadProject,
MAX_YOUTUBE_URL_LENGTH: 2048,
saveProject: analysisMocks.saveProject,
selectLocalAudioSource: analysisMocks.selectLocalAudioSource,
startAnalysisJob: analysisMocks.startAnalysisJob,
subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates
}));

vi.mock("./features/score/ScoreView", () => ({
ScoreView: () => <div>Score view</div>
}));

/**
* Security Notes:
* - This test supplies only synthetic analysis data and safe allowlisted local-intake failure copy.
* - No filesystem, network, subprocess, or native-picker authority is exercised.
*/
describe("App local selection failure visibility", () => {
beforeEach(() => {
for (const mock of Object.values(analysisMocks)) {
mock.mockReset();
}
analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false);
analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
analysisMocks.loadProject.mockResolvedValue(createDemoRehearsalSong());
analysisMocks.selectLocalAudioSource.mockResolvedValue({
ok: false,
error: {
code: "invalid_request",
message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis."
}
});
});

it("surfaces local replacement failure even when a rehearsal result is already loaded", async () => {
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Open Project" }));
await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));

await waitFor(() => {
expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});
});

it("clears a stale local selection failure after a saved project opens successfully", async () => {
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));
await waitFor(() =>
expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy()
);

fireEvent.click(screen.getByRole("button", { name: "Open Project" }));

await waitFor(() => expect(screen.getByText("Late Night Set")).toBeTruthy());
expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
});

it("replaces a stale project-load error with the later local recovery action", async () => {
analysisMocks.loadProject.mockRejectedValueOnce(new Error("synthetic project load failure"));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Open Project" }));
await waitFor(() => expect(analysisMocks.loadProject).toHaveBeenCalledTimes(1));

fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));

await waitFor(() => {
expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});
});
});
177 changes: 176 additions & 1 deletion apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,10 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));

await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
});
expect(screen.getByRole("alert").textContent).toMatch(/choose a wav, mp3, flac, or m4a file/i);
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});

Expand All @@ -450,6 +451,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});

Expand All @@ -463,9 +465,159 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});

it("starts local file intake from the selection-failure next action", async () => {
tauriInvoke
.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."))
.mockResolvedValueOnce(bootstrapResponse());

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));

await waitFor(() => {
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));

await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Choose another song" })).toBeNull();
expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
});

it("keeps the empty workspace silent when the local picker is cancelled", async () => {
tauriInvoke.mockRejectedValueOnce(new Error("User cancelled"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));

await waitFor(() => {
expect(tauriInvoke).toHaveBeenCalledWith("select_local_audio_source");
});

expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
});

it("keeps an admitted song when a replacement picker is cancelled", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
.mockRejectedValueOnce(new Error("User cancelled"));

render(<App />);

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: /choose local audio/i }));
await waitFor(() => {
expect(tauriInvoke).toHaveBeenCalledTimes(2);
});

expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("heading", { name: "That file can't start tonight" })).toBeNull();
});

it("keeps the selection-failure next action when a replacement picker is cancelled", async () => {
tauriInvoke
.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."))
.mockRejectedValueOnce(new Error("User cancelled"));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));
await waitFor(() => {
expect(tauriInvoke).toHaveBeenCalledTimes(2);
});

expect(screen.getByRole("heading", { name: "That file can't start tonight" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose another song" })).toBeEnabled();
});

it("localizes the local selection-failure next action", async () => {
const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."));

try {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /로컬 오디오 선택/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "그 파일로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy();
});
expect(screen.getByRole("button", { name: "다른 곡 선택하기" })).toBeTruthy();
} finally {
languageSpy.mockRestore();
}
});

it("allows only one local picker while the first selection is pending", async () => {
let resolveSelection: ((value: ReturnType<typeof bootstrapResponse>) => void) | undefined;
tauriInvoke.mockImplementation(
() =>
new Promise((resolve) => {
resolveSelection = resolve;
})
);

render(<App />);

const headerAction = screen.getByRole("button", { name: /choose local audio/i });
fireEvent.click(headerAction);

await waitFor(() => {
expect(headerAction).toBeDisabled();
});

fireEvent.click(headerAction);
expect(tauriInvoke).toHaveBeenCalledTimes(1);

resolveSelection?.(bootstrapResponse());
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
});

it("disables the selection-failure next action while a replacement picker is pending", async () => {
tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis."));

render(<App />);
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});

let resolveSelection: ((value: ReturnType<typeof bootstrapResponse>) => void) | undefined;
tauriInvoke.mockImplementation(
() =>
new Promise((resolve) => {
resolveSelection = resolve;
})
);

fireEvent.click(screen.getByRole("button", { name: "Choose another song" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Choose another song" })).toBeDisabled();
expect(screen.getByRole("button", { name: /choose local audio/i })).toBeDisabled();
});

resolveSelection?.(bootstrapResponse());
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
});

it("starts an analysis job and renders the returned rehearsal result", async () => {
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
Expand Down Expand Up @@ -1595,6 +1747,29 @@ describe("App", () => {
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});

it("surfaces a local intake failure from the Score view by returning to the workspace", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});

fireEvent.click(screen.getAllByRole("button", { name: /^Score$/i })[0]!);
await waitFor(() => expect(screen.getByRole("region", { name: "Score" })).toBeTruthy());

mockLocalAudioSelectionResult = {
ok: false,
error: { code: "invalid_request", message: "" }
};
fireEvent.click(screen.getByRole("button", { name: "Choose local audio" }));

await waitFor(() => expect(screen.queryByRole("region", { name: "Score" })).toBeNull());
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose another song" })).toBeTruthy();
});

it("switches to the Score view from the compact mobile navigation", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);
Expand Down
Loading
Loading