Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2069b1c
feat(workspace): name Paste another YouTube link after import fails
seonghobae Aug 22, 2026
960a0ef
test(workspace): expose stale YouTube recovery state
seonghobae Aug 22, 2026
343c878
test: correct YouTube recovery action query
seonghobae Aug 22, 2026
40d88f9
fix: clear stale YouTube recovery state
seonghobae Aug 22, 2026
7ee714d
test: cover pending YouTube import guard
seonghobae Aug 22, 2026
ab7c6e0
test: bind YouTube recovery fixture to shared contract
seonghobae Aug 22, 2026
5a72ab5
test: preserve redacted YouTube import exception detail
seonghobae Aug 22, 2026
eb03fd0
fix: preserve sanitized YouTube import exception detail
seonghobae Aug 22, 2026
804189d
test: cover YouTube recovery dismissal across active views
seonghobae Aug 22, 2026
885ec04
fix: make YouTube recovery dismissible across views
seonghobae Aug 22, 2026
389fcb1
test(workspace): announce YouTube failure reason
seonghobae Aug 22, 2026
d4b96da
fix(workspace): announce YouTube failure detail
seonghobae Aug 22, 2026
ef723ee
test: avoid ambiguous YouTube error text query
seonghobae Aug 22, 2026
52bb480
test: pin YouTube import transition races
seonghobae Aug 22, 2026
ffa7e05
fix: serialize YouTube import transitions
seonghobae Aug 22, 2026
0630576
test: keep recovery controls outside alerts
seonghobae Aug 22, 2026
54f5a59
fix: separate alert semantics from recovery action
seonghobae Aug 22, 2026
475df5f
Merge branch 'develop' into feat/youtube-import-failure-next-action
seonghobae Aug 25, 2026
2921f82
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
821ae34
test(workspace): allow duplicated youtube failure copy
seonghobae Aug 28, 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 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.

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.
- 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

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 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 75 additions & 13 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand All @@ -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();
});
});

Expand All @@ -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();
});
});

Expand All @@ -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();
});
});

Expand All @@ -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();
});
});

Expand All @@ -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();
});
Expand All @@ -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();
});
Expand All @@ -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(<App />);
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(<App />);
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(<App />);
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);
Expand Down Expand Up @@ -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();
});
});

Expand All @@ -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();
});
});

Expand Down
70 changes: 63 additions & 7 deletions apps/desktop/src/App.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@ export function App() {
/** Documented. */
const handleStartAnalysis = async () => {
const submittedBootstrap = selectedBootstrap;
setSelectionError(null);
setSelectionErrorSource(null);
setJobError(null);
setJobResult(null);
setJobResultBootstrap(null);
Expand Down Expand Up @@ -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;
}
Comment thread
seonghobae marked this conversation as resolved.

setJobError(null);
setSelectionError(null);
setSelectionErrorSource(null);
const normalizedUrl = youtubeUrl.trim();
Expand All @@ -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}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setSelectionErrorSource("youtube");
Comment thread
seonghobae marked this conversation as resolved.
} finally {
setIsImporting(false);
}
};

/** Focus the YouTube field so the next paste replaces the failed link. */
const handlePasteAnotherYoutubeLink = () => {
youtubeInputRef.current?.focus();
youtubeInputRef.current?.select();
};
Comment thread
seonghobae marked this conversation as resolved.

/** 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();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
setYoutubeUrl("");
dismissYoutubeSelectionError();
};

/** Documented. */
const handleLoadProject = async () => {
try {
const song = await loadProject();
setSelectionError(null);
setSelectionErrorSource(null);
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
Expand Down Expand Up @@ -511,13 +545,26 @@ export function App() {
if (analysisInFlight || isStarting) {
return <LoadingState />;
}
if (selectionError && selectionErrorSource === "youtube") {
return (
<ErrorState
error={selectionError}
title={t("youtubeImportFailureTitle")}
guidance={t("youtubeImportFailureGuidance")}
actionLabel={t("pasteAnotherYoutubeLink")}
onAction={handlePasteAnotherYoutubeLink}
actionDisabled={isImporting}
/>
);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
if (jobResult) {
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
}
return <EmptyState />;
};

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]) => {
Expand Down Expand Up @@ -701,7 +748,10 @@ export function App() {
placeholder={t("youtubePlaceholder")}
value={youtubeUrl}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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")}
Expand Down Expand Up @@ -737,7 +787,7 @@ export function App() {
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3 2xl:flex 2xl:flex-wrap 2xl:justify-end">
<Button
onClick={handleLoadProject}
disabled={analysisInFlight || isStarting}
disabled={analysisInFlight || isStarting || isImporting}
variant="outline"
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100 hover:bg-white/10 hover:text-white"
aria-label={t("openProject")}
Expand Down Expand Up @@ -825,7 +875,13 @@ export function App() {
)}

{selectionError && (
<div id={selectionErrorSource === "youtube" ? "selection-error" : undefined} className="rounded-full border border-rose-300/25 bg-rose-400/10 px-3 py-1 font-semibold text-rose-100" role="alert" aria-live="assertive" aria-atomic="true">
<div
id={selectionErrorSource === "youtube" ? "selection-error" : undefined}
className="rounded-full border border-rose-300/25 bg-rose-400/10 px-3 py-1 font-semibold text-rose-100"
role={selectionErrorSource === "youtube" ? undefined : "alert"}
aria-live={selectionErrorSource === "youtube" ? undefined : "assertive"}
aria-atomic={selectionErrorSource === "youtube" ? undefined : "true"}
>
Comment thread
seonghobae marked this conversation as resolved.
{selectionError}
</div>
)}
Expand All @@ -842,7 +898,7 @@ export function App() {
</header>

<section className="animate-in fade-in duration-500 ease-out fill-mode-both">
{currentView === "score" && jobResult ? (
{currentView === "score" && jobResult && !youtubeRecoveryVisible ? (
<ScoreView
song={jobResult}
projectId={jobResultBootstrap?.projectId ?? null}
Expand Down
Loading
Loading