From 98fdf7977b8efb4705340f8df06abea263f1a6e4 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 17 Aug 2026 08:19:55 +0000 Subject: [PATCH 01/14] feat(workspace): put the next rehearsal action on empty and error cards Empty and failed workspace states now start the next step: choose a local audio file, paste a YouTube URL, choose another file, or start over. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + apps/desktop/src/App.test.tsx | 72 +++++++++++------ apps/desktop/src/App.tsx | 30 ++++++- .../workspace/WorkspaceStates.test.tsx | 80 +++++++++++++++++++ .../features/workspace/WorkspaceStates.tsx | 61 +++++++++++++- apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + 10 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..d180354d5 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. +- Customer-facing empty and error copy must enable the next action (choose local audio, paste a YouTube URL, choose another file, or start over). Do not leave those states as text-only cards. - 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..f87b5041c 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. +- Empty and failed workspace cards are actionable state cards. They must expose the next rehearsal action instead of describing the gap and stopping. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..8d87b06e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Empty and failed workspace cards now expose the next rehearsal action: choose a local audio file, paste a YouTube URL, choose another file, or start over. - 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 82c2c704a..1cdd4380d 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. +Workspace empty and error cards must keep a visible next action (choose local audio, paste a YouTube URL, choose another file, or start over). + 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/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..478d7651c 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -280,6 +280,28 @@ describe("App", () => { expect(sourceControls).toHaveTextContent(/Import YouTube/i); }); + it("lets the empty workspace card start a local-audio pick", async () => { + mockLocalAudioSelectionResult = { + ok: true, + bootstrap: { + projectId: "proj-empty-next", + source: { + sourceKind: "local-audio", + sourceMode: "reference", + fileName: "rehearsal-take.wav", + format: "wav" + } + } + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + + await waitFor(() => { + expect(screen.getByTitle("rehearsal-take.wav")).toBeTruthy(); + }); + }); + it("caps the YouTube URL input before import-path validation", () => { render(); @@ -400,7 +422,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); @@ -425,7 +447,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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(); @@ -445,7 +467,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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(); @@ -458,7 +480,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy(); @@ -478,7 +500,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -518,7 +540,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -552,7 +574,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -585,7 +607,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -622,7 +644,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -659,7 +681,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -714,7 +736,7 @@ describe("App", () => { const { unmount } = render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -749,7 +771,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -774,7 +796,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -809,7 +831,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -838,7 +860,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -912,7 +934,7 @@ describe("App", () => { try { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -920,7 +942,7 @@ describe("App", () => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -956,7 +978,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -983,7 +1005,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1006,7 +1028,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1029,7 +1051,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1046,7 +1068,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1066,7 +1088,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1083,7 +1105,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + 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 })); @@ -1130,7 +1152,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent(/choose a wav, mp3, flac, or m4a file/i); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..84c804a2b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -464,6 +464,17 @@ export function App() { } }; + /** Documented. */ + const handleFocusYoutubeUrl = () => { + youtubeInputRef.current?.focus(); + }; + + /** Documented. */ + const handleStartOverAfterError = () => { + setJobError(null); + setJobStatus(null); + }; + /** Documented. */ const handleClearYoutubeUrl = () => { youtubeInputRef.current?.focus(); @@ -506,7 +517,15 @@ export function App() { /** Documented. */ const renderWorkspaceState = () => { if (jobError) { - return ; + return ( + { + void handleChooseLocalAudio(); + }} + onStartOver={handleStartOverAfterError} + /> + ); } if (analysisInFlight || isStarting) { return ; @@ -514,7 +533,14 @@ export function App() { if (jobResult) { return ; } - return ; + return ( + { + void handleChooseLocalAudio(); + }} + onFocusYoutube={handleFocusYoutubeUrl} + /> + ); }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; 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..56c46ae5e --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,80 @@ +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", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("offers choose-audio and YouTube next actions on the empty card", () => { + const onChooseLocalAudio = vi.fn(); + const onFocusYoutube = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" })); + + expect(onChooseLocalAudio).toHaveBeenCalledTimes(1); + expect(onFocusYoutube).toHaveBeenCalledTimes(1); + }); + + it("keeps empty-state actions inert when no handlers are wired", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" })); + expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy(); + }); + + it("offers choose-another-file and start-over after a failed analysis", () => { + const onChooseLocalAudio = vi.fn(); + const onStartOver = vi.fn(); + render( + + ); + + expect(screen.getByText("Decoder rejected the file")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Choose another file" })); + fireEvent.click(screen.getByRole("button", { name: "Start over" })); + expect(onChooseLocalAudio).toHaveBeenCalledTimes(1); + expect(onStartOver).toHaveBeenCalledTimes(1); + }); + + it("renders a failed analysis card without detail copy", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Choose another file" })); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText("Decoder rejected the file")).toBeNull(); + }); + + it("localizes empty, loading, and error next-action copy", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "로컬 오디오 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "YouTube 주소 붙여넣기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "처음부터 다시" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..965ff46e2 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,9 +1,16 @@ 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, Youtube } from "lucide-react"; + +/** Next-action handlers for the empty rehearsal workspace. */ +export interface EmptyStateProps { + onChooseLocalAudio?: () => void; + onFocusYoutube?: () => void; +} /** Documented. */ -export function EmptyState() { +export function EmptyState({ onChooseLocalAudio, onFocusYoutube }: EmptyStateProps = {}) { const t = createTranslator(detectPreferredLocale()); return ( @@ -13,6 +20,27 @@ export function EmptyState() {

{t("workspaceReadyToAnalyzeTitle")}

{t("workspaceEmptyState")}

+
+ + +
); @@ -38,8 +66,15 @@ export function LoadingState() { ); } +/** Next-action handlers after a failed analysis. */ +export interface ErrorStateProps { + error?: string; + onChooseLocalAudio?: () => void; + onStartOver?: () => void; +} + /** Documented. */ -export function ErrorState({ error }: { error?: string }) { +export function ErrorState({ error, onChooseLocalAudio, onStartOver }: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); return ( @@ -49,6 +84,26 @@ export function ErrorState({ error }: { error?: string }) {

{t("workspaceErrorState")}

{error &&

{error}

} +
+ + +
); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..baf331afc 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -30,8 +30,12 @@ "workspaceReadyToAnalyzeTitle": "Ready to Analyze", "workspaceAnalyzingAudioTitle": "Analyzing Audio", "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "workspaceEmptyChooseAudio": "Choose a local audio file", + "workspaceEmptyPasteYoutube": "Paste a YouTube URL", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", + "workspaceErrorChooseAnother": "Choose another file", + "workspaceErrorStartOver": "Start over", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", "workspaceRehearsalFallback": "Rehearsal Workspace", "workspaceTempoLabel": "Tempo", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..b3f47a7d9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -30,8 +30,12 @@ "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "workspaceEmptyChooseAudio": "로컬 오디오 파일 선택", + "workspaceEmptyPasteYoutube": "YouTube 주소 붙여넣기", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", + "workspaceErrorChooseAnother": "다른 파일 선택", + "workspaceErrorStartOver": "처음부터 다시", "workspaceRehearsalMapLabel": "오늘의 합주 지도", "workspaceRehearsalFallback": "합주 작업 공간", "workspaceTempoLabel": "템포", From 5a2197f654d6e78ce031e7e8c61abe13300a730a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 17 Aug 2026 08:20:32 +0000 Subject: [PATCH 02/14] fix(workspace): use shipped Link2 icon for the YouTube next action --- apps/desktop/src/features/workspace/WorkspaceStates.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 965ff46e2..6f928f868 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,7 +1,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle, Upload, Youtube } from "lucide-react"; +import { Loader2, Music, AlertCircle, Upload, Link2 } from "lucide-react"; /** Next-action handlers for the empty rehearsal workspace. */ export interface EmptyStateProps { @@ -37,7 +37,7 @@ export function EmptyState({ onChooseLocalAudio, onFocusYoutube }: EmptyStatePro 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("workspaceEmptyPasteYoutube")} > -