Skip to content
Closed
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
8acc846
feat: implement V1.1 metadata-only local handoff
seonghobae Apr 25, 2026
2aef571
feat: implement V2 Advanced Rehearsal Collaboration Features
seonghobae Apr 25, 2026
052280a
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Apr 25, 2026
538a174
Address review feedback: annotations, deep links, validation, workspa…
seonghobae Jun 10, 2026
b4816cb
Address review feedback: file validation, audio resolution, markdown …
seonghobae Jun 10, 2026
aa7a10d
fix: package-lock and type dependencies
seonghobae Jun 10, 2026
83c8315
Merge remote-tracking branch 'origin/develop' into feature/issue-150-…
seonghobae Jun 10, 2026
37f4976
Merge remote-tracking branch 'origin/develop' into feature/issue-152-…
seonghobae Jun 10, 2026
adb92cb
Merge branch 'develop' into feature/issue-150-local-handoff
seonghobae Jun 10, 2026
857c062
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Jun 10, 2026
442084a
fix(tests): resolve failing archive import/export tests
seonghobae Jun 10, 2026
1642c56
Merge PR 156 to fix typecheck dependencies
seonghobae Jun 10, 2026
682bf7f
fix(desktop): support RehearsalWorkspace in App.tsx
seonghobae Jun 10, 2026
4ca24df
fix(lint): resolve lint errors in PR 158
seonghobae Jun 10, 2026
462c1ee
fix(deps): regenerate lockfile to fix CI
seonghobae Jun 10, 2026
9f44679
fix(deps): regenerate lockfile to fix CI
seonghobae Jun 10, 2026
2c17a0e
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Jun 11, 2026
38cfa3c
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Jun 11, 2026
22e19bb
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Jun 11, 2026
a688f45
Merge branch 'develop' into feature/issue-152-collaboration
seonghobae Jun 11, 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 apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@tauri-apps/api": "^2.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jszip": "^3.10.1",
"lucide-react": "^1.11.0",
"react": "^19.2.4",
"react-dom": "^19.2.5",
Expand Down
51 changes: 39 additions & 12 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { type RehearsalSong } from "@bandscope/shared-types";

const tauriInvoke = vi.fn();
const mockLoadProject = vi.fn();
Expand Down Expand Up @@ -28,6 +29,21 @@ vi.mock("./lib/analysis", async (importActual) => {
};
});


function wrapInWorkspace(song: RehearsalSong) {
return {
id: "ws-test",
title: song.title || "Untitled",
workspaceVersion: 1,
songs: [{
id: "pack-test",
packState: "ready",
song: song,
sourceLabel: "Test Source"
}]
};
}

function succeededResult() {
return {
jobId: "job-1",
Expand Down Expand Up @@ -186,7 +202,7 @@ describe("App", () => {
});

it("renders the loaded song as a dark rehearsal command board", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
Expand All @@ -201,7 +217,7 @@ describe("App", () => {
});

it("renders a rehearsal song structure timeline from real section ranges", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
Expand All @@ -219,7 +235,7 @@ describe("App", () => {
});

it("does not show unavailable analysis metrics as detected facts", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
Expand All @@ -244,7 +260,7 @@ describe("App", () => {
label: "chorus",
confidence: { level: "high", source: "model", notes: "The chorus form is clear." }
});
mockLoadProject.mockResolvedValueOnce(loadedProject);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(loadedProject));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
Expand Down Expand Up @@ -660,7 +676,7 @@ describe("App", () => {


it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
Expand Down Expand Up @@ -723,7 +739,7 @@ describe("App", () => {
});

it("saves a project successfully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand All @@ -738,12 +754,23 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(mockSaveProject).toHaveBeenCalledWith(succeededResult().result);
expect(mockSaveProject).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(String),
title: expect.any(String),
workspaceVersion: 1,
songs: expect.arrayContaining([
expect.objectContaining({
song: succeededResult().result
})
])
})
);
});
});

it("handles saving a project failure gracefully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand All @@ -763,7 +790,7 @@ describe("App", () => {
});

it("ignores cancellation when saving a project with Error object", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand All @@ -786,7 +813,7 @@ describe("App", () => {
});

it("handles saving a project failure with string error gracefully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand All @@ -806,7 +833,7 @@ describe("App", () => {
});

it("ignores cancellation when saving a project with string error", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand All @@ -829,7 +856,7 @@ describe("App", () => {
});

it("handles song update from workspace", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
mockLoadProject.mockResolvedValueOnce(wrapInWorkspace(succeededResult().result));
render(<App />);

// Load first to get jobResult populated
Expand Down
109 changes: 79 additions & 30 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
type AnalysisJobRequest,
type AnalysisJobStatus,
type ProjectBootstrapSummary,
type RehearsalSong
type RehearsalSong,
type RehearsalWorkspace
} from "@bandscope/shared-types";
import {
createDefaultAnalysisRequest,
Expand Down Expand Up @@ -145,7 +146,7 @@ export function App() {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []);
const [jobStatus, setJobStatus] = useState<AnalysisJobStatus | null>(null);
const [jobResult, setJobResult] = useState<RehearsalSong | null>(null);
const [jobResult, setJobResult] = useState<RehearsalWorkspace | null>(null);
const [jobError, setJobError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [selectedBootstrap, setSelectedBootstrap] = useState<ProjectBootstrapSummary | null>(null);
Expand Down Expand Up @@ -173,7 +174,18 @@ export function App() {
const nextStatus = await getAnalysisJobStatus(jobStatus.jobId);
setJobStatus(nextStatus);
if (nextStatus.state === "succeeded" && nextStatus.result) {
setJobResult(nextStatus.result);
const workspace: RehearsalWorkspace = {
id: "ws-" + Date.now(),
title: nextStatus.result.title || "Untitled",
workspaceVersion: 1,
songs: [{
id: "pack-" + Date.now(),
packState: "ready",
song: nextStatus.result,
sourceLabel: selectedRequest.sourceLabel
}]
};
setJobResult(workspace);
setJobError(null);
}
if (nextStatus.state === "failed") {
Expand Down Expand Up @@ -220,7 +232,18 @@ export function App() {
const nextStatus = await startAnalysisJob(selectedRequest);
setJobStatus(nextStatus);
if (nextStatus.state === "succeeded" && nextStatus.result) {
setJobResult(nextStatus.result);
const workspace: RehearsalWorkspace = {
id: "ws-" + Date.now(),
title: nextStatus.result.title || "Untitled",
workspaceVersion: 1,
songs: [{
id: "pack-" + Date.now(),
packState: "ready",
song: nextStatus.result,
sourceLabel: selectedRequest.sourceLabel
}]
};
setJobResult(workspace);
}
if (nextStatus.state === "failed") {
setJobError(nextStatus.error?.message ?? t("analysisCouldNotStart"));
Expand Down Expand Up @@ -280,8 +303,8 @@ export function App() {
/** Documented. */
const handleLoadProject = async () => {
try {
const song = await loadProject();
setJobResult(song);
const workspace = await loadProject();
setJobResult(workspace);
setJobError(null);
setSelectedBootstrap(null);
setJobStatus(null);
Expand Down Expand Up @@ -309,7 +332,16 @@ export function App() {

/** Documented. */
const handleSongUpdate = (updatedSong: RehearsalSong) => {
setJobResult(updatedSong);
if (jobResult) {
setJobResult({
...jobResult,
songs: jobResult.songs.map(p =>
p.packState === "ready" && p.song.id === updatedSong.id
? { ...p, song: updatedSong }
: p
)
});
}
};

/** Documented. */
Expand All @@ -320,8 +352,16 @@ export function App() {
if (analysisInFlight || isStarting) {
return <LoadingState />;
}
if (jobResult) {
return <Workspace song={jobResult} onSongUpdate={handleSongUpdate} />;
if (jobResult && jobResult.songs.length > 0) {
const firstReady = jobResult.songs.find(p => p.packState === "ready");
if (firstReady && firstReady.packState === "ready") {
return (
<Workspace
song={firstReady.song}
onSongUpdate={handleSongUpdate}
/>
);
}
}
return <EmptyState />;
};
Expand Down Expand Up @@ -423,27 +463,33 @@ export function App() {
))}
</nav>

<header className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<MetricCard icon={<Clock3 className="size-5" aria-hidden="true" />} label="Tempo" value="Pending" detail="Awaiting reliable detection" accent="text-sky-300" />
<MetricCard icon={<KeyRound className="size-5" aria-hidden="true" />} label="Key" value="Pending" detail="No trusted key yet" accent="text-cyan-300" />
<MetricCard icon={<Wand2 className="size-5" aria-hidden="true" />} label="Transpose" value="Pending" detail="Review after key detection" accent="text-blue-300" />
<ConfidenceMetric song={jobResult} />
<MetricCard icon={<Star className="size-5 fill-amber-300 text-amber-300" aria-hidden="true" />} label="Priority" value={priorityLabel(jobResult)} detail={jobResult?.exportSummary?.headline ?? "Choose or open audio"} accent="text-amber-300" />
</header>

<section aria-label="Source controls" className="mb-4 rounded-3xl border border-white/10 bg-slate-950/72 p-4 shadow-[0_18px_60px_rgba(0,0,0,0.25)] backdrop-blur-xl">
<div className="grid gap-4 2xl:grid-cols-[1.4fr_minmax(0,1fr)_auto] 2xl:items-center">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-cyan-300">
{jobResult ? "READY • REHEARSAL" : "SYNCED • LOCAL"}
</p>
<h1 className="mt-2 text-3xl font-black tracking-tight text-white sm:text-4xl">
{jobResult ? "Rehearsal Console" : "Workspace Home"}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">
{jobResult?.exportSummary?.headline ?? "Turn a song into a practical rehearsal view."}
</p>
</div>
{(() => {
const firstReadyPack = jobResult?.songs.find(p => p.packState === "ready");
const firstReadySong = firstReadyPack && firstReadyPack.packState === "ready" ? firstReadyPack.song : null;

return (
<>
<header className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<MetricCard icon={<Clock3 className="size-5" aria-hidden="true" />} label="Tempo" value="Pending" detail="Awaiting reliable detection" accent="text-sky-300" />
<MetricCard icon={<KeyRound className="size-5" aria-hidden="true" />} label="Key" value="Pending" detail="No trusted key yet" accent="text-cyan-300" />
<MetricCard icon={<Wand2 className="size-5" aria-hidden="true" />} label="Transpose" value="Pending" detail="Review after key detection" accent="text-blue-300" />
<ConfidenceMetric song={firstReadySong} />
<MetricCard icon={<Star className="size-5 fill-amber-300 text-amber-300" aria-hidden="true" />} label="Priority" value={priorityLabel(firstReadySong)} detail={firstReadySong?.exportSummary?.headline ?? "Choose or open audio"} accent="text-amber-300" />
</header>

<section aria-label="Source controls" className="mb-4 rounded-3xl border border-white/10 bg-slate-950/72 p-4 shadow-[0_18px_60px_rgba(0,0,0,0.25)] backdrop-blur-xl">
<div className="grid gap-4 2xl:grid-cols-[1.4fr_minmax(0,1fr)_auto] 2xl:items-center">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-cyan-300">
{jobResult ? "READY • REHEARSAL" : "SYNCED • LOCAL"}
</p>
<h1 className="mt-2 text-3xl font-black tracking-tight text-white sm:text-4xl">
{jobResult ? "Rehearsal Console" : "Workspace Home"}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">
{firstReadySong?.exportSummary?.headline ?? "Turn a song into a practical rehearsal view."}
</p>
</div>

<div className="grid min-w-0 gap-3 sm:grid-cols-[auto_1fr_auto] sm:items-center 2xl:grid-cols-[auto_1fr]">
<Button
Expand Down Expand Up @@ -547,6 +593,9 @@ export function App() {
</div>
</div>
</section>
</>
);
})()}

<section className="animate-in fade-in duration-500 ease-out fill-mode-both">
{renderWorkspaceState()}
Expand Down
14 changes: 7 additions & 7 deletions apps/desktop/src/lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
parseAnalysisJobStatus,
parseAnalysisJobRequest,
parseProjectBootstrapSummary,
parseRehearsalSong,
parseRehearsalWorkspace,
type AnalysisJobError,
type AnalysisJobRequest,
type AnalysisJobStatus,
type ProjectBootstrapSummary,
type RehearsalSong
type RehearsalWorkspace
} from "@bandscope/shared-types";

type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
Expand Down Expand Up @@ -275,13 +275,13 @@ export async function importYoutubeUrl(url: string): Promise<LocalAudioSelection
}

/** Documented. */
export async function saveProject(song: RehearsalSong): Promise<void> {
const parsedSong = parseRehearsalSong(song);
await invokeAnalysis("save_project", { payload: parsedSong });
export async function saveProject(workspace: RehearsalWorkspace): Promise<void> {
const parsedWorkspace = parseRehearsalWorkspace(workspace);
await invokeAnalysis("save_project", { payload: parsedWorkspace });
}

/** Documented. */
export async function loadProject(): Promise<RehearsalSong> {
export async function loadProject(): Promise<RehearsalWorkspace> {
const response = await invokeAnalysis("load_project");
return parseRehearsalSong(response);
return parseRehearsalWorkspace(response);
}
31 changes: 31 additions & 0 deletions apps/desktop/src/lib/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Annotation } from "@bandscope/shared-types";

/**
* Merges two arrays of annotations, keeping unique ones and sorting by timestamp.
*
* @param existing - The existing annotations.
* @param incoming - The incoming annotations.
* @returns The merged annotations array.
*/
export function mergeAnnotations(existing: Annotation[] = [], incoming: Annotation[] = []): Annotation[] {
const merged = [...existing];
const existingIds = new Set(existing.map((a) => a.id));

for (const ann of incoming) {
if (!existingIds.has(ann.id)) {
merged.push(ann);
existingIds.add(ann.id);
}
}

const mergedWithIndex = merged.map((item, index) => ({ item, index }));
mergedWithIndex.sort((a, b) => {
const ta = Date.parse(a.item.timestamp);
const tb = Date.parse(b.item.timestamp);
const tsa = Number.isFinite(ta) ? ta : 0;
const tsb = Number.isFinite(tb) ? tb : 0;
if (tsa !== tsb) return tsa - tsb;
return a.index - b.index;
});
return mergedWithIndex.map(x => x.item);
}
Loading
Loading