Conversation
Add a collapsible mixer strip below the session grid showing per-track volume fader, pan knob, solo/mute buttons, and level meters. The mixer can be toggled via a button at the bottom of the session view. - SessionMixerStrip: horizontal strip with fader, pan knob, S/M buttons, meter - SessionMixer: container rendering strips for all tracks with toggle - Integrated below the clip grid in SessionView - 15 unit tests covering rendering, interaction, and store updates Closes #925 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an integrated, collapsible mixer panel to the Session View so users can adjust per-track volume/pan and toggle solo/mute without leaving the clip grid workflow.
Changes:
- Introduces
SessionMixercontainer with show/hide toggle and renders one strip per track. - Adds
SessionMixerStripcomponent with volume “fader” + meters, pan knob, solo/mute buttons, and track color accent. - Integrates the new mixer panel into
SessionViewand adds unit tests for rendering and interactions.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/components/session/SessionView.tsx | Wires the new session mixer panel into the session view layout. |
| src/components/session/SessionMixer.tsx | Implements the collapsible mixer container and connects mixer actions to the Zustand project store. |
| src/components/session/SessionMixerStrip.tsx | Implements the per-track mixer UI (metering, volume/pan control, solo/mute). |
| src/components/session/tests/SessionMixer.test.tsx | Adds unit tests for the mixer container and strip interactions/store updates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {tracks.map((track) => ( | ||
| <SessionMixerStrip | ||
| key={track.id} | ||
| trackId={track.id} | ||
| trackName={track.displayName} | ||
| trackColor={track.color} | ||
| volume={track.volume} | ||
| pan={track.pan ?? 0} | ||
| muted={track.muted} | ||
| soloed={track.soloed} | ||
| onVolumeChange={(v) => handleVolumeChange(track.id, v)} | ||
| onPanChange={(v) => handlePanChange(track.id, v)} | ||
| onMuteToggle={() => handleMuteToggle(track.id, track.muted)} | ||
| onSoloToggle={() => handleSoloToggle(track.id, track.soloed)} | ||
| /> | ||
| ))} |
There was a problem hiding this comment.
When visible is false, the mixer still renders all SessionMixerStrip children inside a 0px-tall container. Each strip starts its own requestAnimationFrame loop for meters, so the mixer continues doing per-frame work even while hidden. Consider conditionally rendering the strips only when visible is true (or passing visible down to pause metering) to avoid unnecessary CPU usage when the panel is collapsed.
| {tracks.map((track) => ( | |
| <SessionMixerStrip | |
| key={track.id} | |
| trackId={track.id} | |
| trackName={track.displayName} | |
| trackColor={track.color} | |
| volume={track.volume} | |
| pan={track.pan ?? 0} | |
| muted={track.muted} | |
| soloed={track.soloed} | |
| onVolumeChange={(v) => handleVolumeChange(track.id, v)} | |
| onPanChange={(v) => handlePanChange(track.id, v)} | |
| onMuteToggle={() => handleMuteToggle(track.id, track.muted)} | |
| onSoloToggle={() => handleSoloToggle(track.id, track.soloed)} | |
| /> | |
| ))} | |
| {visible && | |
| tracks.map((track) => ( | |
| <SessionMixerStrip | |
| key={track.id} | |
| trackId={track.id} | |
| trackName={track.displayName} | |
| trackColor={track.color} | |
| volume={track.volume} | |
| pan={track.pan ?? 0} | |
| muted={track.muted} | |
| soloed={track.soloed} | |
| onVolumeChange={(v) => handleVolumeChange(track.id, v)} | |
| onPanChange={(v) => handlePanChange(track.id, v)} | |
| onMuteToggle={() => handleMuteToggle(track.id, track.muted)} | |
| onSoloToggle={() => handleSoloToggle(track.id, track.soloed)} | |
| /> | |
| ))} |
| const handleMuteToggle = useCallback((trackId: string, currentMuted: boolean) => { | ||
| updateTrack(trackId, { muted: !currentMuted }); | ||
| }, [updateTrack]); | ||
|
|
||
| const handleSoloToggle = useCallback((trackId: string, currentSoloed: boolean) => { | ||
| updateTrack(trackId, { soloed: !currentSoloed }); | ||
| }, [updateTrack]); |
There was a problem hiding this comment.
Mute/solo handling here does not account for group tracks. Elsewhere in the UI (e.g. MixerPanel/TrackHeader) group tracks use setGroupMuted / setGroupSoloed so the action cascades to child tracks. With the current implementation, muting/soloing a group from the Session mixer will behave differently than other mixers.
| const handlePanChange = useCallback((trackId: string, pan: number) => { | ||
| // updateTrackMixer handles pan updates | ||
| useProjectStore.getState().updateTrackMixer(trackId, { pan }); | ||
| }, []); |
There was a problem hiding this comment.
handlePanChange reaches into the store via useProjectStore.getState() and uses an empty dependency array. For consistency with the rest of the codebase (and to avoid bypassing React hooks patterns), select updateTrackMixer via useProjectStore((s) => s.updateTrackMixer) and include it in the callback dependencies.
| role="slider" | ||
| aria-valuemin={0} | ||
| aria-valuemax={100} | ||
| aria-valuenow={Math.round(volume * 100)} |
There was a problem hiding this comment.
This slider is implemented as a non-focusable div with role="slider". For accessibility, ARIA sliders should be keyboard operable and focusable (e.g. tabIndex=0 plus arrow/home/end key handling), otherwise screen-reader users can discover it but not change the value without a pointer.
| aria-valuenow={Math.round(volume * 100)} | |
| aria-valuenow={Math.round(volume * 100)} | |
| aria-orientation="horizontal" | |
| tabIndex={0} | |
| onKeyDown={(e) => { | |
| const step = 0.05; | |
| const largeStep = 0.1; | |
| let newVolume: number | null = null; | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| case 'ArrowDown': | |
| newVolume = Math.max(0, volume - step); | |
| break; | |
| case 'ArrowRight': | |
| case 'ArrowUp': | |
| newVolume = Math.min(1, volume + step); | |
| break; | |
| case 'PageDown': | |
| newVolume = Math.max(0, volume - largeStep); | |
| break; | |
| case 'PageUp': | |
| newVolume = Math.min(1, volume + largeStep); | |
| break; | |
| case 'Home': | |
| newVolume = 0; | |
| break; | |
| case 'End': | |
| newVolume = 1; | |
| break; | |
| default: | |
| break; | |
| } | |
| if (newVolume !== null) { | |
| e.preventDefault(); | |
| // Prefer prop callback if available; otherwise fall back to direct state update if present. | |
| // eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| // @ts-ignore onVolumeChange is expected to be provided via props. | |
| onVolumeChange?.(newVolume); | |
| } | |
| }} |
| it('does not render strips when hidden', () => { | ||
| render(<SessionMixer visible={false} onToggle={() => {}} />); | ||
|
|
||
| const mixer = screen.getByTestId('session-mixer'); | ||
| // The inner container should have height 0 | ||
| const container = mixer.querySelector('.overflow-hidden'); | ||
| expect(container).toBeInTheDocument(); | ||
| expect((container as HTMLElement).style.height).toBe('0px'); | ||
| }); |
There was a problem hiding this comment.
The test name/comment says strips are not rendered when hidden, but the assertion only checks the container height style. Given the implementation still mounts strips (and their metering loops), this test currently wouldn't catch regressions in actual rendering behavior. Either assert that no strips are present when hidden, or update the test name to reflect what’s being checked.
| fireEvent.pointerDown(fader, { clientX: 50 }); | ||
| expect(onVolumeChange).toHaveBeenCalled(); |
There was a problem hiding this comment.
This interaction test doesn't stub getBoundingClientRect(), so in JSDOM the element width may be 0 and the handler can end up calling onVolumeChange with NaN. Consider mocking the bounding rect (as done in the store-update test) and asserting the callback receives a sane normalized value.
| fireEvent.pointerDown(fader, { clientX: 50 }); | |
| expect(onVolumeChange).toHaveBeenCalled(); | |
| // Stub getBoundingClientRect so width is non-zero and calculations are stable in JSDOM | |
| const boundingRect = { | |
| x: 0, | |
| y: 0, | |
| top: 0, | |
| left: 0, | |
| right: 100, | |
| bottom: 10, | |
| width: 100, | |
| height: 10, | |
| toJSON: () => ({}), | |
| } as DOMRect; | |
| vi.spyOn(fader as any, 'getBoundingClientRect').mockReturnValue(boundingRect); | |
| fireEvent.pointerDown(fader, { clientX: 50 }); | |
| expect(onVolumeChange).toHaveBeenCalledWith(expect.any(Number)); | |
| const value = onVolumeChange.mock.calls[0][0] as number; | |
| expect(Number.isNaN(value)).toBe(false); | |
| expect(value).toBeGreaterThanOrEqual(0); | |
| expect(value).toBeLessThanOrEqual(1); |
|
Triage: Hold for Phase 2 — Session view integrated mixer is a UX improvement but not blocking. Defer until core session view features are stable. Generated by Claude Code |
Summary
SessionMixerStripcomponent renders a compact horizontal strip (~40px) per track with track color accentSessionMixercontainer with show/hide toggle, integrated intoSessionViewCloses #925
Test plan
npx tsc --noEmitpasses with 0 errorsnpm testpasses (2770 tests, 15 new)npm run buildsucceeds🤖 Generated with Claude Code