Skip to content
Draft
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
70629e0
feat(workspace): open existing rehearsal surfaces from the sidebar
seonghobae Aug 17, 2026
6979f88
fix(lint): bind exported variable JSDoc to export declaration
seonghobae Aug 17, 2026
e85d185
revert(lint): preserve repository-wide export JSDoc selector
seonghobae Aug 17, 2026
c098551
fix(workspace): attach JSDoc to exported nav constants
seonghobae Aug 17, 2026
0841bbc
test(workspace): reproduce nav review regressions
seonghobae Aug 17, 2026
548d70e
fix(workspace): separate navigation requests and describe disabled items
seonghobae Aug 17, 2026
c18ed87
fix(workspace): replay transpose navigation per request
seonghobae Aug 17, 2026
d854578
test(workspace): lock repeated transpose navigation semantics
seonghobae Aug 17, 2026
79a535d
test(workspace): reproduce no-role transpose dead end
seonghobae Aug 17, 2026
cb90b9f
fix(workspace): add no-role transpose recovery copy
seonghobae Aug 17, 2026
0a6b418
fix(workspace): localize no-role transpose recovery
seonghobae Aug 17, 2026
469b1db
fix(workspace): keep transpose navigation recoverable without roles
seonghobae Aug 17, 2026
562b4de
fix(security): raise pdfjs-dist, nanoid, and undici HIGH floors
cursoragent Aug 17, 2026
a69800a
revert(security): keep dependency remediation in #783
seonghobae Aug 17, 2026
6a1f7a3
test(workspace): reproduce stale role state on song replacement
seonghobae Aug 17, 2026
33cd74b
test(workspace): cover stale role after song replacement
seonghobae Aug 17, 2026
b263ac4
fix(workspace): clear stale role on song replacement
seonghobae Aug 17, 2026
a346703
Merge branch 'develop' into feat/workspace-nav-existing-surfaces
seonghobae Aug 25, 2026
5be4768
test(workspace): reset surface on project replacement
seonghobae Aug 26, 2026
bc755eb
fix(workspace): clear stale surface on project load
seonghobae Aug 26, 2026
4c90867
test(workspace): cover Import focus during analysis
seonghobae Aug 26, 2026
4d8bed6
fix(workspace): keep Import focus actionable while busy
seonghobae Aug 26, 2026
bbcccf6
Merge remote-tracking branch 'origin/develop' into HEAD
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.
- Sidebar items that already have a rehearsal surface must take the player there and name the next action. Only Stem Lab stays “coming soon.”
- 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.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Last updated: 2026-03-11
- BandScope is not only a shell around chord labels, stems, and ranges.
- The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority.
- These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer.
- The desktop sidebar must jump to existing workspace surfaces (import, export, sections, roles, cues, transpose). Disabled items name the next action. Stem Lab remains unavailable until that product lane exists.

## Analysis target model

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

- Sidebar Import, Export, Sections, Roles, Cues, and Transpose now open the matching rehearsal surface instead of saying coming soon. Stem Lab stays unavailable until that lane exists. Disabled items tell the player to analyze or open a project first.
- 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.

Sidebar Import/Export/Sections/Roles/Cues/Transpose must open the matching rehearsal surface. Stem Lab stays coming soon. Disabled nav copy must name the next action.

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
51 changes: 42 additions & 9 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ describe("App", () => {
tauriWindow.__TAURI_INVOKE__ = tauriInvoke;
});

it("renders the rehearsal cockpit shell before analysis starts", () => {
it("renders the rehearsal cockpit shell before analysis starts", async () => {
render(<App />);

expect(screen.getByRole("img", { name: /BandScope circular equalizer mark/i })).toBeTruthy();
Expand All @@ -222,25 +222,34 @@ describe("App", () => {
const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i });
const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" });
expect(activePrimaryNavButton).toHaveAttribute("aria-current", "page");
for (const name of ["Import", "Export"]) {
for (const name of ["Import"]) {
const navButton = within(primaryNav).getByRole("button", { name });
expect(navButton).not.toHaveAttribute("aria-disabled");
expect(navButton).toHaveAttribute("title", "Choose a local file or paste a YouTube URL");
}
for (const name of ["Export", "Sections", "Roles", "Cues", "Transpose"]) {
const navButton = within(primaryNav).getByRole("button", { name });
expect(navButton).toHaveAttribute("aria-disabled", "true");
expect(navButton).toHaveAttribute("title", "Coming soon");
expect(navButton).toHaveAttribute("title", "Analyze a song or open a project first");
expect(navButton).not.toBeDisabled();
}
const stemLab = within(primaryNav).getByRole("button", { name: "Stem Lab" });
expect(stemLab).toHaveAttribute("aria-disabled", "true");
expect(stemLab).toHaveAttribute("title", "Coming soon");
fireEvent.click(within(primaryNav).getByRole("button", { name: "Import" }));
expect(activePrimaryNavButton).toHaveAttribute("aria-current", "page");
expect(within(primaryNav).getByRole("button", { name: "Import" })).toHaveAttribute("aria-current", "page");
await waitFor(() => {
expect(document.getElementById("source-controls-choose-audio")).toHaveFocus();
});
const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const activeCompactNavButton = within(compactNav).getByRole("button", { name: "Workspace compact view" });
const activeCompactNavButton = within(compactNav).getByRole("button", { name: "Import compact view" });
expect(activeCompactNavButton).toHaveAttribute("aria-current", "page");
for (const name of ["Import", "Export"]) {
for (const name of ["Export", "Sections"]) {
const navButton = within(compactNav).getByRole("button", { name: `${name} compact view` });
expect(navButton).toHaveAttribute("aria-disabled", "true");
expect(navButton).toHaveAttribute("title", "Coming soon");
expect(navButton).toHaveAttribute("title", "Analyze a song or open a project first");
expect(navButton).not.toBeDisabled();
}
fireEvent.click(within(compactNav).getByRole("button", { name: "Import compact view" }));
expect(activeCompactNavButton).toHaveAttribute("aria-current", "page");
expect(screen.getByText(/^Tempo$/i)).toBeTruthy();
expect(screen.getByText(/^Key$/i)).toBeTruthy();
expect(screen.getByText(/Local-first/i)).toBeTruthy();
Expand Down Expand Up @@ -304,6 +313,30 @@ describe("App", () => {
expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy();
});

it("opens existing rehearsal surfaces from the sidebar after a song is ready", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

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

const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i });
const exportButton = within(primaryNav).getByRole("button", { name: "Export" });
expect(exportButton).not.toHaveAttribute("aria-disabled");
expect(exportButton).toHaveAttribute("title", "Download tonight's cue sheet or chart");
fireEvent.click(exportButton);
expect(exportButton).toHaveAttribute("aria-current", "page");
expect(document.getElementById("workspace-surface-export")).toHaveFocus();

fireEvent.click(within(primaryNav).getByRole("button", { name: "Transpose" }));
await waitFor(() => {
expect(document.getElementById("workspace-surface-transpose")).toHaveFocus();
});
expect(within(primaryNav).getByRole("button", { name: "Stem Lab" })).toHaveAttribute("title", "Coming soon");
});

it("renders a rehearsal song structure timeline from real section ranges", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);
Expand Down
186 changes: 129 additions & 57 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
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import {
AudioWaveform,
CircleHelp,
Expand Down Expand Up @@ -46,6 +46,7 @@ import {
import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n";
import { ScoreView } from "./features/score/ScoreView";
import { Workspace } from "./features/workspace/Workspace";
import { SOURCE_CONTROLS_FOCUS_ID, type WorkspaceReadySurface } from "./features/workspace/workspaceNav";
import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -59,18 +60,28 @@ 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 NavSurface = "import" | WorkspaceReadySurface;

const NAV_SURFACE_TITLE_KEYS = {
import: "navImportNextAction",
export: "navExportNextAction",
sections: "navSectionsNextAction",
roles: "navRolesNextAction",
cues: "navCuesNextAction",
transpose: "navTransposeNextAction"
} as const satisfies Record<NavSurface, TranslationKey>;

const NAV_ITEMS = [
{ labelKey: "navWorkspace", icon: Home, view: "workspace" },
{ labelKey: "navImport", icon: Upload, view: null },
{ labelKey: "navExport", icon: Save, view: null },
{ labelKey: "navSections", icon: ListMusic, view: null },
{ labelKey: "navRoles", icon: Users, view: null },
{ labelKey: "navStemLab", icon: AudioWaveform, view: null },
{ labelKey: "navCues", icon: Sparkles, view: null },
{ labelKey: "navTranspose", icon: SlidersHorizontal, view: null },
{ labelKey: "navScore", icon: FileMusic, view: "score" }
] as const satisfies readonly { labelKey: TranslationKey; icon: LucideIcon; view: RehearsalView | null }[];
{ labelKey: "navWorkspace", icon: Home, view: "workspace" as const, surface: null },
{ labelKey: "navImport", icon: Upload, view: "workspace" as const, surface: "import" as const },
{ labelKey: "navExport", icon: Save, view: "workspace" as const, surface: "export" as const },
{ labelKey: "navSections", icon: ListMusic, view: "workspace" as const, surface: "sections" as const },
{ labelKey: "navRoles", icon: Users, view: "workspace" as const, surface: "roles" as const },
{ labelKey: "navStemLab", icon: AudioWaveform, view: null, surface: null },
{ labelKey: "navCues", icon: Sparkles, view: "workspace" as const, surface: "cues" as const },
{ labelKey: "navTranspose", icon: SlidersHorizontal, view: "workspace" as const, surface: "transpose" as const },
{ labelKey: "navScore", icon: FileMusic, view: "score" as const, surface: null }
] as const satisfies readonly { labelKey: TranslationKey; icon: LucideIcon; view: RehearsalView | null; surface: NavSurface | null }[];

const BRAND_BAR_HEIGHTS = ["h-3", "h-5", "h-7", "h-4", "h-6"] as const;

Expand Down Expand Up @@ -264,6 +275,8 @@ export function App() {
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [activeView, setActiveView] = useState<RehearsalView>("workspace");
const [requestedSurface, setRequestedSurface] = useState<NavSurface | null>(null);
Comment thread
seonghobae marked this conversation as resolved.
const [requestedSurfaceRequestId, setRequestedSurfaceRequestId] = useState(0);
const activeJobIdRef = useRef<string | null>(null);
const youtubeInputRef = useRef<HTMLInputElement | null>(null);

Expand Down Expand Up @@ -512,7 +525,15 @@ export function App() {
return <LoadingState />;
}
if (jobResult) {
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
return (
<Workspace
song={jobResult}
sourceBootstrap={jobResultBootstrap}
onSongUpdate={handleSongUpdate}
requestedSurface={requestedSurface === "import" ? null : requestedSurface}
requestedSurfaceRequestId={requestedSurfaceRequestId}
/>
);
}
return <EmptyState />;
};
Expand All @@ -521,18 +542,52 @@ export function App() {

/** Resolve label, enablement, and active state for one sidebar item. */
const navButtonState = (item: (typeof NAV_ITEMS)[number]) => {
const enabled = item.view === "workspace" || (item.view === "score" && jobResult !== null);
const enabled =
item.view === "workspace"
? item.surface === null || item.surface === "import" || jobResult !== null
: item.view === "score" && jobResult !== null;
const active =
enabled &&
(item.view === "score"
? currentView === "score"
: currentView === "workspace" &&
(item.surface === null ? requestedSurface === null : requestedSurface === item.surface));
const title = enabled
? item.surface
? t(NAV_SURFACE_TITLE_KEYS[item.surface])
: undefined
: item.view === "score"
? t("scoreNavDisabledHint")
: item.surface
? t("navNeedsAnalysisHint")
: t("comingSoon");
return {
label: t(item.labelKey),
enabled,
active: enabled && item.view === currentView,
title: enabled ? undefined : item.view === "score" ? t("scoreNavDisabledHint") : t("comingSoon")
active,
title
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
};

/** Switch the main content to the clicked rehearsal view. */
const handleNavSelect = (view: RehearsalView) => {
setActiveView(view);
/** Open an existing rehearsal surface or stay on the coming-soon items. */
const handleNavSelect = (item: (typeof NAV_ITEMS)[number]) => {
if (item.view === "score") {
setActiveView("score");
setRequestedSurface(null);
return;
}
setActiveView("workspace");
if (item.surface === "import") {
setRequestedSurface("import");
window.setTimeout(() => {
document.getElementById(SOURCE_CONTROLS_FOCUS_ID)?.focus();
}, 0);
return;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
setRequestedSurface(item.surface);
if (item.surface !== null) {
setRequestedSurfaceRequestId((current) => current + 1);
}
};

return (
Expand Down Expand Up @@ -563,27 +618,35 @@ export function App() {
<nav aria-label={t("primaryRehearsalViewsAriaLabel")} className="space-y-2">
{NAV_ITEMS.map((item) => {
const { label, enabled, active, title } = navButtonState(item);
const { icon: Icon, view } = item;
const { icon: Icon } = item;
const descriptionId = enabled ? undefined : `primary-${item.labelKey}-disabled-description`;

return (
<button
key={item.labelKey}
type="button"
aria-current={active ? "page" : undefined}
aria-disabled={enabled ? undefined : true}
title={title}
onClick={enabled && view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={`flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
>
<Icon className="size-5" aria-hidden="true" />
{label}
</button>
<Fragment key={item.labelKey}>
<button
type="button"
aria-current={active ? "page" : undefined}
aria-disabled={enabled ? undefined : true}
aria-describedby={descriptionId}
title={title}
onClick={enabled ? () => handleNavSelect(item) : blockInactiveNavActivation}
className={`flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
>
<Icon className="size-5" aria-hidden="true" />
{label}
</button>
{descriptionId ? (
<span id={descriptionId} className="sr-only">
{title}
</span>
) : null}
</Fragment>
);
})}
</nav>
Expand Down Expand Up @@ -639,28 +702,36 @@ export function App() {
<nav aria-label={t("compactRehearsalViewsAriaLabel")} className="mb-4 flex gap-2 overflow-x-auto rounded-2xl border border-white/10 bg-slate-950/72 p-2 backdrop-blur-xl lg:hidden">
{NAV_ITEMS.map((item) => {
const { label, enabled, active, title } = navButtonState(item);
const { icon: Icon, view } = item;
const { icon: Icon } = item;
const descriptionId = enabled ? undefined : `compact-${item.labelKey}-disabled-description`;

return (
<button
key={item.labelKey}
type="button"
aria-current={active ? "page" : undefined}
aria-label={`${label} ${t("compactViewSuffix")}`}
aria-disabled={enabled ? undefined : true}
title={title}
onClick={enabled && view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
>
<Icon className="size-4" aria-hidden="true" />
{label}
</button>
<Fragment key={item.labelKey}>
<button
type="button"
aria-current={active ? "page" : undefined}
aria-label={`${label} ${t("compactViewSuffix")}`}
aria-disabled={enabled ? undefined : true}
aria-describedby={descriptionId}
title={title}
onClick={enabled ? () => handleNavSelect(item) : blockInactiveNavActivation}
className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
>
<Icon className="size-4" aria-hidden="true" />
{label}
</button>
{descriptionId ? (
<span id={descriptionId} className="sr-only">
{title}
</span>
) : null}
</Fragment>
);
})}
</nav>
Expand All @@ -681,6 +752,7 @@ export function App() {

<div className="grid min-w-0 gap-3 xl:grid-cols-[auto_minmax(0,1fr)] xl:items-center">
<Button
id={SOURCE_CONTROLS_FOCUS_ID}
onClick={handleChooseLocalAudio}
disabled={analysisInFlight || isStarting || isImporting}
variant="secondary"
Expand Down
Loading
Loading