Skip to content

feat: multi-plugin chains per track + sidechain UI#896

Merged
ChuxiJ merged 1 commit intomainfrom
feat/sidechain-chain-ui
Mar 26, 2026
Merged

feat: multi-plugin chains per track + sidechain UI#896
ChuxiJ merged 1 commit intomainfrom
feat/sidechain-chain-ui

Conversation

@ChuxiJ
Copy link

@ChuxiJ ChuxiJ commented Mar 25, 2026

Summary

  • Track-grouped plugin list: ActivePlugins groups instances by track with collapsible headers
  • Drag to reorder: Reorder effects within a track's chain via drag & drop
  • Sidechain source selector: Track dropdown in VST3PluginPanel for sidechain input routing
  • Store actions: reorderPlugins(trackId, orderedIds), setSidechain(instanceId, sourceTrackId)
  • hasSidechainInput flag on VST3ActiveInstance type

Test plan

  • 2680 tests pass (15 new)
  • 0 type errors

Closes #895

🤖 Generated with Claude Code

- ActivePlugins groups instances by track with collapsible track headers
- Drag to reorder effects within a track's chain (reorderPlugins action)
- Sidechain source selector in VST3PluginPanel (track dropdown)
- setSidechain sends bridge message for routing sidechain audio
- hasSidechainInput flag on VST3ActiveInstance
- 15 new tests (grouping, reorder, sidechain selector, store actions)
- 2680 tests pass, 0 type errors

Refs #895

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 25, 2026 16:43
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds per-track effect chains with drag-reordering and introduces sidechain routing UI/state for VST3 instances.

Changes:

  • Extend VST3ActiveInstance with sidechain capability + selected source track.
  • Add pluginOrder plus store actions for per-track reordering and sidechain routing (with bridge forwarding).
  • Update UI/tests: track-grouped ActivePlugins list with drag & drop, and a sidechain selector in VST3PluginPanel.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/types/vst3.ts Adds sidechain-related fields to active instance type.
src/store/vst3Store.ts Introduces pluginOrder, reorderPlugins, and setSidechain actions.
src/store/tests/vst3Store.test.ts Adds tests for reordering and sidechain state updates.
src/components/plugins/VST3PluginPanel.tsx Adds sidechain source dropdown driven by project tracks.
src/components/plugins/tests/VST3PluginPanel.test.tsx Adds sidechain selector rendering/behavior tests.
src/components/plugins/ActivePlugins.tsx Groups instances by track and implements drag-and-drop reordering UI.
src/components/plugins/tests/ActivePlugins.test.tsx Adds tests for track grouping, ordering, and draggability.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +49 to +50
const handleDragStart = useCallback(
(e: React.DragEvent, instanceId: string, trackId: string) => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.DragEvent is used in type positions but React isn’t imported as a type/namespace in this file. In TSX projects using the automatic JSX runtime, this commonly fails with “Cannot find namespace 'React'”. Import the appropriate event type(s) from react (e.g., import type { DragEvent } from 'react') and update the signatures, or add import type React from 'react' if you want to keep React.DragEvent.

Copilot uses AI. Check for mistakes.
[],
);

const handleDragEnd = useCallback((e: React.DragEvent) => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.DragEvent is used in type positions but React isn’t imported as a type/namespace in this file. In TSX projects using the automatic JSX runtime, this commonly fails with “Cannot find namespace 'React'”. Import the appropriate event type(s) from react (e.g., import type { DragEvent } from 'react') and update the signatures, or add import type React from 'react' if you want to keep React.DragEvent.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +72
const handleDragOver = useCallback(
(e: React.DragEvent, trackId: string, index: number) => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.DragEvent is used in type positions but React isn’t imported as a type/namespace in this file. In TSX projects using the automatic JSX runtime, this commonly fails with “Cannot find namespace 'React'”. Import the appropriate event type(s) from react (e.g., import type { DragEvent } from 'react') and update the signatures, or add import type React from 'react' if you want to keep React.DragEvent.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +89
const handleDrop = useCallback(
(e: React.DragEvent, trackId: string, dropIndex: number) => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.DragEvent is used in type positions but React isn’t imported as a type/namespace in this file. In TSX projects using the automatic JSX runtime, this commonly fails with “Cannot find namespace 'React'”. Import the appropriate event type(s) from react (e.g., import type { DragEvent } from 'react') and update the signatures, or add import type React from 'react' if you want to keep React.DragEvent.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +140
{Array.from(trackGroups.entries()).map(([trackId, group]) => (
<div key={trackId} data-testid={`track-group-${trackId}`}>
{/* Track header */}
<div className="flex items-center gap-1.5 px-2 py-1 mb-1">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
{trackNameMap.get(trackId) ?? trackId}
</span>
<span className="truncate text-[10px] text-zinc-500 max-w-[80px]">
{trackNameMap.get(inst.trackId) ?? inst.trackId}
</span>
{/* Chevron */}
<svg
className={`h-3 w-3 text-zinc-500 transition-transform ${
expandedId === inst.instanceId ? 'rotate-90' : ''
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>

{/* Expanded panel */}
{expandedId === inst.instanceId && (
<div className="mt-1">
<VST3PluginPanel instanceId={inst.instanceId} />
</div>
)}
<span className="text-[9px] text-zinc-600">({group.length})</span>
</div>
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions “collapsible headers”, but the current track header is purely presentational—there’s no collapsed/expanded state per track (only expandedId per plugin instance). Either implement track-level collapse (e.g., a collapsedTrackIds state and click target on the header) or update the PR description to match the implemented behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +205
reorderPlugins: (trackId: string, instanceIds: string[]) => {
const { instances, pluginOrder } = get();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both actions compute the next state using snapshots from get() and then call set() with a fully materialized object. In Zustand, this pattern can clobber concurrent updates (e.g., another action updating instances or pluginOrder between get() and set()). Prefer the functional form set((state) => ...) so merges are based on the latest state.

Copilot uses AI. Check for mistakes.
Comment on lines +217 to +229
set({ pluginOrder: { ...pluginOrder, [trackId]: validOrder } });
},

setSidechain: (instanceId: string, sourceTrackId: string | null) => {
const { instances } = get();
const inst = instances[instanceId];
if (!inst) return;
set({
instances: {
...instances,
[instanceId]: { ...inst, sidechainSourceTrackId: sourceTrackId },
},
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both actions compute the next state using snapshots from get() and then call set() with a fully materialized object. In Zustand, this pattern can clobber concurrent updates (e.g., another action updating instances or pluginOrder between get() and set()). Prefer the functional form set((state) => ...) so merges are based on the latest state.

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +217
const validOrder = instanceIds.filter((id) => trackInstanceIds.has(id));
if (validOrder.length === 0) return;

set({ pluginOrder: { ...pluginOrder, [trackId]: validOrder } });
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validOrder can contain duplicates if instanceIds contains duplicates, and it may omit valid instances on the track (which can lead to inconsistent ordering semantics across the app if other code assumes pluginOrder[trackId] is a full permutation). Consider normalizing by deduping while preserving order, and appending any missing track instance IDs at the end. If clearing ordering is a valid use case, consider explicitly supporting instanceIds.length === 0 to remove/reset pluginOrder[trackId] instead of early-returning.

Suggested change
const validOrder = instanceIds.filter((id) => trackInstanceIds.has(id));
if (validOrder.length === 0) return;
set({ pluginOrder: { ...pluginOrder, [trackId]: validOrder } });
// If an empty array is provided, treat it as a request to clear/reset
// the explicit ordering for this track.
if (instanceIds.length === 0) {
const { [trackId]: _removed, ...rest } = pluginOrder;
set({ pluginOrder: rest });
return;
}
// Normalize the requested order:
// - keep only IDs that belong to this track
// - dedupe while preserving first occurrence
// - append any missing track instance IDs at the end
const seen = new Set<string>();
const normalizedOrder: string[] = [];
for (const id of instanceIds) {
if (!trackInstanceIds.has(id)) continue;
if (seen.has(id)) continue;
seen.add(id);
normalizedOrder.push(id);
}
for (const id of trackInstanceIds) {
if (!seen.has(id)) {
normalizedOrder.push(id);
}
}
if (normalizedOrder.length === 0) return;
set({ pluginOrder: { ...pluginOrder, [trackId]: normalizedOrder } });

Copilot uses AI. Check for mistakes.
@ChuxiJ ChuxiJ merged commit e342424 into main Mar 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: sidechain routing — multi-plugin chains per track

2 participants