diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..5babb1a40 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 YouTube import fails, customer-facing copy must name Paste another YouTube link as the next rehearsal action. The field-level message stays redacted; the recovery action only focuses the existing YouTube field.
- 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..1176679c5 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.
+- Failed YouTube import names Paste another YouTube link as the next action. Buyer-visible copy stays redacted and does not retry the network from the recovery card.
## Security source
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..3974db067 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- When a YouTube link cannot be imported, the workspace names Paste another YouTube link as the next rehearsal action and keeps the failed URL ready to replace.
- 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..07f377f5e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,9 @@ 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.
-Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
+When YouTube import fails, the workspace must name Paste another YouTube link as the next action. The recovery action only focuses the YouTube field.
+
+Agent execution and delegation rules live in `docs/agents/README.md`. PR continuity and duplicate-handling policy live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..5b7a48b24 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -1162,11 +1162,13 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- const alert = screen.getByRole("alert");
- expect(alert).toHaveTextContent(/This video is age restricted/i);
- expect(alert).toHaveAttribute("id", "selection-error");
+ const fieldError = document.getElementById("selection-error");
+ expect(fieldError).toHaveTextContent(/This video is age restricted/i);
+ expect(fieldError).toHaveAttribute("id", "selection-error");
expect(input).toHaveAttribute("aria-invalid", "true");
- expect(input).toHaveAttribute("aria-describedby", alert.id);
+ expect(input).toHaveAttribute("aria-describedby", "selection-error");
+ expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1182,7 +1184,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Network Error/i)).toBeTruthy();
+ expect(screen.getAllByText(/Network Error/i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1197,7 +1200,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1209,7 +1213,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1221,7 +1226,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1233,7 +1239,8 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
expect(tauriInvoke).not.toHaveBeenCalled();
});
@@ -1246,7 +1253,8 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
expect(tauriInvoke).not.toHaveBeenCalled();
});
@@ -1259,11 +1267,63 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
expect(tauriInvoke).not.toHaveBeenCalled();
});
+ it("focuses the YouTube field from the import-failure next action", async () => {
+ render();
+ const input = screen.getByRole("textbox", { name: /YouTube URL/i });
+ fireEvent.change(input, { target: { value: "not-a-url" } });
+ fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Paste another YouTube link" }));
+
+ expect(document.activeElement).toBe(input);
+ expect(input).toHaveValue("not-a-url");
+ });
+
+ it("redacts URL-shaped YouTube import diagnostics before they become buyer copy", async () => {
+ tauriInvoke.mockRejectedValueOnce(
+ new Error("download failed: https://youtube.com/watch?v=secret-token")
+ );
+
+ render();
+ const input = screen.getByPlaceholderText(/YouTube URL.../i);
+ fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } });
+ fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy();
+ });
+ expect(document.getElementById("selection-error")?.textContent).toMatch(/download failed: \[link\]/i);
+ expect(screen.queryByText(/secret-token/i)).toBeNull();
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
+ });
+
+ it("localizes the YouTube import-failure next action", async () => {
+ const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
+
+ try {
+ render();
+ const input = screen.getByRole("textbox", { name: /유튜브 URL/i });
+ fireEvent.change(input, { target: { value: "not-a-url" } });
+ 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("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
@@ -1523,7 +1583,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
@@ -1548,7 +1609,8 @@ describe("App", () => {
fireEvent.click(button);
await waitFor(() => {
- expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy();
+ expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0);
+ expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy();
});
});
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index f3d678454..2d7196d5d 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -388,6 +388,8 @@ export function App() {
/** Documented. */
const handleStartAnalysis = async () => {
const submittedBootstrap = selectedBootstrap;
+ setSelectionError(null);
+ setSelectionErrorSource(null);
setJobError(null);
setJobResult(null);
setJobResultBootstrap(null);
@@ -429,8 +431,21 @@ export function App() {
setJobStatus(null);
};
- /** Documented. */
+ /**
+ * Import the current YouTube URL through BandScope's existing downloader boundary.
+ *
+ * Security Notes:
+ * - The URL field and bridge failure detail are untrusted input.
+ * - URL admission remains fail-closed through `isSupportedYoutubeUrl` before the downloader is invoked.
+ * - Buyer-visible failures pass through `safeErrorDetail`, which removes URL, local-path, and secret-shaped details.
+ * - The recovery action only focuses/selects the existing field; it never performs a second network request.
+ */
const handleImportYoutube = async () => {
+ if (isImporting) {
+ return;
+ }
+
+ setJobError(null);
setSelectionError(null);
setSelectionErrorSource(null);
const normalizedUrl = youtubeUrl.trim();
@@ -456,24 +471,43 @@ export function App() {
setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed")));
setSelectionErrorSource("youtube");
}
- } catch {
- setSelectionError(t("youtubeImportFailed"));
+ } catch (error) {
+ const fallback = t("youtubeImportFailed");
+ const detail = safeErrorDetail(error, fallback);
+ setSelectionError(detail === fallback ? fallback : `${fallback} ${detail}`);
setSelectionErrorSource("youtube");
} finally {
setIsImporting(false);
}
};
+ /** Focus the YouTube field so the next paste replaces the failed link. */
+ const handlePasteAnotherYoutubeLink = () => {
+ youtubeInputRef.current?.focus();
+ youtubeInputRef.current?.select();
+ };
+
+ /** Clear only the YouTube-owned recovery state, preserving unrelated source errors. */
+ const dismissYoutubeSelectionError = () => {
+ if (selectionErrorSource === "youtube") {
+ setSelectionError(null);
+ setSelectionErrorSource(null);
+ }
+ };
+
/** Documented. */
const handleClearYoutubeUrl = () => {
youtubeInputRef.current?.focus();
setYoutubeUrl("");
+ dismissYoutubeSelectionError();
};
/** Documented. */
const handleLoadProject = async () => {
try {
const song = await loadProject();
+ setSelectionError(null);
+ setSelectionErrorSource(null);
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
@@ -511,6 +545,18 @@ export function App() {
if (analysisInFlight || isStarting) {
return ;
}
+ if (selectionError && selectionErrorSource === "youtube") {
+ return (
+
+ );
+ }
if (jobResult) {
return ;
}
@@ -518,6 +564,7 @@ export function App() {
};
const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace";
+ const youtubeRecoveryVisible = Boolean(selectionError && selectionErrorSource === "youtube");
/** Resolve label, enablement, and active state for one sidebar item. */
const navButtonState = (item: (typeof NAV_ITEMS)[number]) => {
@@ -701,7 +748,10 @@ export function App() {
placeholder={t("youtubePlaceholder")}
value={youtubeUrl}
maxLength={MAX_YOUTUBE_URL_LENGTH}
- onChange={(e) => setYoutubeUrl(e.target.value)}
+ onChange={(e) => {
+ setYoutubeUrl(e.target.value);
+ dismissYoutubeSelectionError();
+ }}
disabled={analysisInFlight || isStarting || isImporting}
className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300"
aria-label={t("youtubeUrlAriaLabel")}
@@ -737,7 +787,7 @@ export function App() {