feat: multi-plugin chains per track + sidechain UI#896
Conversation
- 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>
There was a problem hiding this comment.
Pull request overview
Adds per-track effect chains with drag-reordering and introduces sidechain routing UI/state for VST3 instances.
Changes:
- Extend
VST3ActiveInstancewith sidechain capability + selected source track. - Add
pluginOrderplus 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.
| const handleDragStart = useCallback( | ||
| (e: React.DragEvent, instanceId: string, trackId: string) => { |
There was a problem hiding this comment.
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.
| [], | ||
| ); | ||
|
|
||
| const handleDragEnd = useCallback((e: React.DragEvent) => { |
There was a problem hiding this comment.
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.
| const handleDragOver = useCallback( | ||
| (e: React.DragEvent, trackId: string, index: number) => { |
There was a problem hiding this comment.
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.
| const handleDrop = useCallback( | ||
| (e: React.DragEvent, trackId: string, dropIndex: number) => { |
There was a problem hiding this comment.
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.
| {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> |
There was a problem hiding this comment.
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.
| reorderPlugins: (trackId: string, instanceIds: string[]) => { | ||
| const { instances, pluginOrder } = get(); |
There was a problem hiding this comment.
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.
| 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 }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
| const validOrder = instanceIds.filter((id) => trackInstanceIds.has(id)); | ||
| if (validOrder.length === 0) return; | ||
|
|
||
| set({ pluginOrder: { ...pluginOrder, [trackId]: validOrder } }); |
There was a problem hiding this comment.
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.
| 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 } }); |
Summary
reorderPlugins(trackId, orderedIds),setSidechain(instanceId, sourceTrackId)hasSidechainInputflag on VST3ActiveInstance typeTest plan
Closes #895
🤖 Generated with Claude Code