Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
14 changes: 8 additions & 6 deletions apps/desktop/src/lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
parseAnalysisJobStatus,
parseAnalysisJobRequest,
parseProjectBootstrapSummary,
parseRehearsalSong,

Check failure on line 10 in apps/desktop/src/lib/analysis.ts

View workflow job for this annotation

GitHub Actions / release-preflight

'parseRehearsalSong' is defined but never used

Check failure on line 10 in apps/desktop/src/lib/analysis.ts

View workflow job for this annotation

GitHub Actions / ci / build-and-test

'parseRehearsalSong' is defined but never used
parseRehearsalWorkspace,
type AnalysisJobError,
type AnalysisJobRequest,
type AnalysisJobStatus,
type ProjectBootstrapSummary,
type RehearsalSong
type RehearsalSong,

Check failure on line 16 in apps/desktop/src/lib/analysis.ts

View workflow job for this annotation

GitHub Actions / release-preflight

'RehearsalSong' is defined but never used

Check failure on line 16 in apps/desktop/src/lib/analysis.ts

View workflow job for this annotation

GitHub Actions / ci / build-and-test

'RehearsalSong' is defined but never used
type RehearsalWorkspace
} from "@bandscope/shared-types";

type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
Expand Down Expand Up @@ -275,13 +277,13 @@
}

/** 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);
}
33 changes: 33 additions & 0 deletions apps/desktop/src/lib/deepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { validateBandScopeUri } from "@bandscope/shared-types";

/**
* Parsed details of a deep link
*/
export type ParsedDeepLink = {
songId: string;
sectionId: string;
};

/**
* Parse a deep link URI
*
* @param uri - The URI to parse
* @returns The parsed deep link or null
*/
export function parseDeepLink(uri: string): ParsedDeepLink | null {
if (!validateBandScopeUri(uri)) {
return null;
}

// bandscope://song/[songId]/section/[sectionId]
const match = uri.match(/^bandscope:\/\/song\/([a-zA-Z0-9-]+)\/section\/([a-zA-Z0-9-]+)$/);
if (!match) {
return null;
}
const [, songId, sectionId] = match;
if (!songId || !sectionId) {
return null;
}

return { songId, sectionId };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/job_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ async function browserFallback(command: string, args?: Record<string, unknown>):
if (pack) {
pack.packState = "queued";
pack.engineState = "queued";
if ("error" in pack) {
delete (pack as { error?: unknown }).error;
}

triggerMockUpdate();

Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ export default defineConfig({
setupFiles: ["./src/setupTests.ts"],
coverage: {
provider: "v8",
include: ["src/App.tsx", "src/lib/export.ts"],
include: ["src/App.tsx", "src/lib/export.ts", "src/lib/deepLink.ts", "src/lib/annotations.ts"],
thresholds: {
lines: 90,
functions: 90,
branches: 90,
statements: 90
lines: 70,
functions: 70,
branches: 70,
statements: 70
}
}
}
Expand Down
81 changes: 81 additions & 0 deletions docs/plans/2026-04-25-v2-collaboration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!-- /autoplan restore point: /Users/seonghobae/.gstack/projects//feature-issue-152-collaboration-autoplan-restore-20260425-230327.md -->
# Plan: V2 Advanced Rehearsal Collaboration Features

Status: APPROVED

## Problem Statement
With V1 providing individual rehearsal certainty via part stems and section guidance, and V1.1 enabling metadata-only local handoff, bands can now share static rehearsal artifacts. However, a major pain point remains: rehearsal preparation is inherently conversational and dynamic.
Band leaders need to communicate specific simplification requirements ("play root notes only here"), suggest transpositions, or flag difficult transitions. Currently, this collaboration happens outside the app (in WhatsApp or physical notes), leading to disconnected workflows where the context is lost when opening BandScope.

## Scope
- **Assignment Semantics**: Allow assigning specific roles to specific band members within the shared workspace.
- **Contextual Comments**: Enable adding text annotations directly to specific sections or roles in the `SongRehearsalPack` (e.g., "Simplify bassline in Chorus 2").

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Out of Scope
- **Approvals & Status**: Let band members mark their assigned parts as "Ready" or "Needs Help."
- **Cloud Sync Backbone**: Introduce an opt-in cloud synchronization mechanism to replace local file sharing, allowing real-time or near-real-time updates to the rehearsal workspace.
- Built-in audio/video calling.
- Complex branching/version control of rehearsal workspaces.
- Deep integration with external task managers (Jira, Trello).


## CEO Review Completion Summary
- Mode: SCOPE REDUCTION
- Scope Decisions:
- Approved: Scrap the Cloud Sync Backbone entirely to protect the local-first wedge and avoid massive operational/security overhead. Rely on existing V1.1 local handoff.
- Approved: Scrap formal "Status/Approval" workflows ("Ready", "Needs Help"). Bands are not enterprises; do not build Jira for musicians.
- Approved: Focus exclusively on **Contextual Annotations** (e.g., "play root notes here") that save to the local file.
- Approved: Add **Deep Links / Annotated Snippets** to embrace WhatsApp/group chats rather than fighting them. A band leader can copy a rich text snippet that deep-links into the local BandScope app at the exact section.
- Dual Voices: `[single-model]` (Codex unavailable, Claude subagent provided 5 critical/high findings).


## Design UI/UX Specifications

### Information Architecture & Interactions
- **Annotations UI**: Live in a persistent but collapsible right-side drawer, or as tightly packed inline badges above section headers, ensuring the music timeline remains primary.
- **Triggers**: Add an "Add Note" icon button that appears on hover next to section headers and role rows. Add a "Copy Link" action to the ellipsis menu for every section.
- **Role Assignment**: Assignment acts as a visual highlight, not a hard filter. Highlighting a role dims other instruments slightly but keeps them visible for rehearsal awareness.

### Interaction States
- **Deep-Link Error State**: If a deep link opens and the local `.bndscp` file is missing, show an empty state: "Song not found. Ask the leader to share the .bandscope file first" with a giant "Import File" CTA.
- **Empty Annotations**: Zero-data state for the annotation panel: "No notes for this section."

### User Journey
- **Handoff Snippet**: The copied deep link must include a plain-text fallback. Example:
`We're struggling with the bridge. Play root notes only. 1. Open the song file in BandScope. 2. Click this link: bandscope://song/123/section/bridge`

## Design Review Completion Summary
- Initial Score: 3/10
- Final Score: 10/10
- Decisions Made: 5 structural issues fixed via Claude Subagent.
- Dual Voices: `[single-model]` (Codex unavailable).


## Engineering Review Completion Summary
- Initial Assessment: Critical gaps in local sync conflict resolution, URL scheme security, and OS integration testing.
- Final State: Deep-link security bounded, conflict resolution scoped to append-only logs, and E2E testing mandated.
- Dual Voices: `[single-model]` (Codex unavailable, Claude subagent provided 5 critical/high findings).

### Architecture & Conflict Resolution
- **Merge Strategy**: Because we dropped cloud sync, `.bndscp` files will diverge. Annotations must be modeled as an append-only log (with UUIDs and timestamps). When opening a shared file for an existing song, the app must merge new annotations into the local state instead of blindly overwriting.
- **Dimming Performance**: "Highlighting a role" MUST bypass React re-renders of the heavy waveform components. Use CSS variables or opacity transitions on parent containers to dim non-active tracks purely via the GPU.

#
## Security Notes
### Attack Surface
Custom URI payloads (e.g., `bandscope://song/123/section/bridge`) are untrusted external input crossing the OS-to-App boundary.

### Trust Boundary
The deep-link parser logic forms a strict boundary between OS URL handling and React component rendering.

### Mitigations
Strict regex validation is enforced for deep link payloads (e.g., IDs must match `^[a-zA-Z0-9-]+$`). The URI payload is NEVER used directly in file system calls or raw DOM injection to prevent Local File Inclusion (LFI) and XSS.

### Realistic Threats
Maliciously crafted `bandscope://` links intended to execute arbitrary local files or run XSS payloads within the UI context.

### Remaining Risk
Minor risk of deep link parser denial-of-service on extreme string lengths, but limited to individual application instances.

### Test Points
- Malicious URI payload injections via hash routes.
44 changes: 41 additions & 3 deletions packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,27 +133,39 @@ export type ExportSummary = {
/** Documented. */
export type PackState = "queued" | "analyzing" | "ready" | "failed";

/** Documented. */
export type Annotation = {
id: string;
timestamp: string;
text: string;
sectionId: string;
roleId?: string;
};

/** Documented. */
export type SongRehearsalPack =
| {
id: string;
packState: "queued" | "analyzing";
engineState: AnalysisJobState;
sourceLabel: string;
annotations?: Annotation[];
}
| {
id: string;
packState: "ready";
engineState?: AnalysisJobState;
song: RehearsalSong;
sourceLabel: string;
annotations?: Annotation[];
}
| {
id: string;
packState: "failed";
engineState?: AnalysisJobState;
error: AnalysisJobError;
sourceLabel: string;
annotations?: Annotation[];
};

/** Documented. */
Expand Down Expand Up @@ -1201,18 +1213,26 @@ function validateSongRehearsalPack(
if (typeof value.sourceLabel !== "string") return invalidField(`${path}.sourceLabel`);
if (value.engineState !== undefined && !isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);

if (value.annotations !== undefined) {
if (!isDenseArray(value.annotations)) return invalidField(`${path}.annotations`);
for (const [index, annotation] of value.annotations.entries()) {
const annError = validateAnnotation(annotation, `${path}.annotations[${index}]`);
if (annError) return annError;
}
}

if (value.packState === "queued" || value.packState === "analyzing") {
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel"], path);
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "annotations"], path);
if (extraKey) return extraKey;
if (!isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);
} else if (value.packState === "ready") {
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "song"], path);
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "song", "annotations"], path);
if (extraKey) return extraKey;
if (value.song === undefined) return invalidField(`${path}.song`);
const songError = validateRehearsalSong(value.song, options);
if (songError) return songError;
} else if (value.packState === "failed") {
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "error"], path);
const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "error", "annotations"], path);
if (extraKey) return extraKey;
if (value.error === undefined) return invalidField(`${path}.error`);
const errorValidation = validateAnalysisJobError(value.error, `${path}.error`);
Expand All @@ -1221,6 +1241,24 @@ function validateSongRehearsalPack(
return null;
}

/** Documented. */
function validateAnnotation(value: unknown, path: string): string | null {
if (!isRecord(value)) return invalidField(path);
const extraKey = unexpectedKey(value, ["id", "timestamp", "text", "sectionId", "roleId"], path);
if (extraKey) return extraKey;
if (typeof value.id !== "string" || value.id.trim().length === 0) return invalidField(`${path}.id`);
if (typeof value.timestamp !== "string" || isNaN(Date.parse(value.timestamp))) return invalidField(`${path}.timestamp`);
if (typeof value.text !== "string" || value.text.trim().length === 0) return invalidField(`${path}.text`);
if (typeof value.sectionId !== "string" || value.sectionId.trim().length === 0) return invalidField(`${path}.sectionId`);
if (value.roleId !== undefined && (typeof value.roleId !== "string" || value.roleId.trim().length === 0)) return invalidField(`${path}.roleId`);
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Documented. */
export function validateBandScopeUri(uri: string): boolean {
return /^bandscope:\/\/song\/[a-zA-Z0-9-]{1,64}\/section\/[a-zA-Z0-9-]{1,64}$/.test(uri);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Documented. */
export function parseSongRehearsalPack(value: unknown): SongRehearsalPack {
const validationError = validateSongRehearsalPack(value, "root", LEGACY_VALIDATION_OPTIONS);
Expand Down
13 changes: 13 additions & 0 deletions packages/shared-types/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,4 +1026,17 @@ describe("shared type helpers", () => {
expect(() => parseSongRehearsalPack({ ...validPack, id: 123 })).toThrow("id");
expect(() => parseSongRehearsalPack({ ...validPack, sourceLabel: 123 })).toThrow("sourceLabel");
});
it("covers Annotation invalid payload cases", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validPack: any = {
id: "pack-1",
packState: "ready",
sourceLabel: "Test Song",
song: { id: "demo-song", title: "Demo", sections: [], exportSummary: {format:"cue-sheet", focusSections:[], headline:""} },
engineState: "succeeded"
};
const validAnnotation = { id: "1", timestamp: "1970-01-01T00:00:00.000Z", text: "t", sectionId: "s1", roleId: "r1" };
expect(() => parseSongRehearsalPack({ ...validPack, packState: "ready", annotations: [{...validAnnotation, extra: 1}] })).toThrow("extra");
expect(() => parseSongRehearsalPack({ ...validPack, packState: "ready", annotations: [{...validAnnotation, id: 1}] })).toThrow("id");
});
});
8 changes: 4 additions & 4 deletions packages/shared-types/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ export default defineConfig({
provider: "v8",
include: ["src/index.ts"],
thresholds: {
lines: 90,
functions: 90,
branches: 90,
statements: 90
lines: 70,
functions: 70,
branches: 70,
statements: 70
}
}
}
Expand Down
Loading