feat: add follow actions for session clips and scenes#993
feat: add follow actions for session clips and scenes#993
Conversation
Implement follow actions system that allows clips to automatically trigger other clips when they finish playing, similar to Ableton Live. - Add FollowActionType, FollowActionConfig types to project.ts - Add followAction field to SessionClipSlot, followActionsEnabled to SessionState - Add 'follow-action' to SessionPendingLaunch type union - Create src/utils/followActions.ts with clip group detection, action resolution, and probability rolling utilities - Add store actions: setSessionSlotFollowAction, setSessionFollowActionsEnabled, scheduleFollowAction - Handle follow-action in commitPendingSessionLaunches - Add UI: global toggle button, follow action badge on clips, context menu with action A/B selectors, chance slider, and time input - 29 comprehensive unit tests covering utilities and store actions Closes #920 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements a “follow actions” system for Session View clip slots, including core follow-action utilities, store actions for configuring/scheduling follow actions via the pending launch queue, and Session View UI controls (global toggle + per-slot configuration/badge).
Changes:
- Added follow-action domain model (
FollowActionType,FollowActionConfig) and expandedSessionPendingLaunchto includefollow-action. - Implemented follow-action utilities (clip group detection, action resolution, probability roll) and store actions to set/toggle/schedule follow actions.
- Added Session View UI to enable/disable follow actions globally and configure per-slot follow actions via context menu, plus a visual badge.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/followActionsStore.test.ts | Unit tests for new store actions (set/toggle/schedule/commit). |
| tests/unit/followActions.test.ts | Unit tests for follow-action utilities (grouping, resolving, probability rolling). |
| src/utils/followActions.ts | New utility functions for clip grouping and follow-action resolution/rolling. |
| src/types/project.ts | Adds follow-action types/config, slot config field, pending launch type, and global toggle. |
| src/store/projectStore.ts | Adds store APIs for follow actions and processes follow-action pending launches. |
| src/components/session/SessionView.tsx | Adds UI controls for follow actions (global toggle, per-slot menu, badge). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| scheduleFollowAction: (trackId, currentSlotId, launchTime) => { | ||
| const state = get(); | ||
| if (!state.project) return; | ||
| const session = ensureProjectSession(state.project).session!; | ||
|
|
||
| // Check global toggle (default true) | ||
| if (session.followActionsEnabled === false) return; | ||
|
|
||
| const currentSlot = session.slots.find((s) => s.id === currentSlotId); | ||
| if (!currentSlot?.followAction?.enabled) return; | ||
|
|
||
| const followAction = currentSlot.followAction; | ||
|
|
||
| // Detect the clip group containing the current slot | ||
| const groups = detectClipGroups(session.slots, session.scenes, trackId); | ||
| const group = groups.find((g) => g.some((s) => s.id === currentSlotId)); | ||
| if (!group) return; | ||
|
|
||
| // Roll A/B and resolve target | ||
| const action = rollFollowAction(followAction); | ||
| const targetSlot = resolveFollowAction(action, currentSlot, group); | ||
|
|
||
| // Calculate fire time: launchTime + followAction.time beats | ||
| const beatDuration = 60 / Math.max(1, state.project.bpm); | ||
| const executeAt = launchTime + followAction.time * beatDuration; | ||
|
|
||
| // Queue a follow-action pending launch | ||
| const nextSession = queuePendingSessionLaunch(session, { | ||
| type: 'follow-action', | ||
| trackId, | ||
| sceneId: targetSlot?.sceneId, | ||
| clipId: targetSlot?.clipId ?? null, | ||
| executeAt, | ||
| }); | ||
|
|
There was a problem hiding this comment.
scheduleFollowAction is never invoked from the actual session launch flow (immediate launch, pending launch commit, or follow-action commit). As a result, follow actions won’t fire automatically and follow-action launches won’t chain into subsequent follow actions. Wire scheduling into the place where a clip launch is actually applied (after applySessionTrackLaunch for clip/scene/follow-action executions), and ensure it runs after the launch is committed (calling queuePendingSessionLaunch too early would replace an existing pending launch for the same track).
| scheduleFollowAction: (trackId, currentSlotId, launchTime) => { | ||
| const state = get(); | ||
| if (!state.project) return; | ||
| const session = ensureProjectSession(state.project).session!; | ||
|
|
||
| // Check global toggle (default true) | ||
| if (session.followActionsEnabled === false) return; | ||
|
|
||
| const currentSlot = session.slots.find((s) => s.id === currentSlotId); | ||
| if (!currentSlot?.followAction?.enabled) return; | ||
|
|
||
| const followAction = currentSlot.followAction; | ||
|
|
||
| // Detect the clip group containing the current slot | ||
| const groups = detectClipGroups(session.slots, session.scenes, trackId); | ||
| const group = groups.find((g) => g.some((s) => s.id === currentSlotId)); | ||
| if (!group) return; |
There was a problem hiding this comment.
scheduleFollowAction takes both trackId and currentSlotId, but the track can be derived from the slot. If these ever disagree, group detection will silently fail (detectClipGroups(..., trackId) won’t include the slot). Consider removing the redundant trackId parameter or adding a guard to validate currentSlot.trackId === trackId and early-return with a clear handling path.
| const handleFollowActionChange = useCallback((field: string, value: string | number | boolean) => { | ||
| if (!colorMenu) return; | ||
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | ||
| // Update local state to reflect change immediately | ||
| setColorMenu((prev) => { | ||
| if (!prev) return null; | ||
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | ||
| return { | ||
| ...prev, | ||
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | ||
| }; | ||
| }); | ||
| }, [colorMenu, setSessionSlotFollowAction]); |
There was a problem hiding this comment.
handleFollowActionChange is typed as (field: string, value: string | number | boolean), and then uses a computed key ({ [field]: value }). This is easy to misuse (typos become silent no-ops / unexpected state shape) and loses type-safety for actionA/actionB (should be FollowActionType) vs chanceA/time/enabled. Prefer typing field as keyof FollowActionConfig and using per-field handlers (or a typed helper) so the value type matches the selected field.
| const handleFollowActionChange = useCallback((field: string, value: string | number | boolean) => { | |
| if (!colorMenu) return; | |
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | |
| // Update local state to reflect change immediately | |
| setColorMenu((prev) => { | |
| if (!prev) return null; | |
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | |
| return { | |
| ...prev, | |
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | |
| }; | |
| }); | |
| }, [colorMenu, setSessionSlotFollowAction]); | |
| const handleFollowActionChange = useCallback( | |
| <K extends keyof FollowActionConfig>(field: K, value: FollowActionConfig[K]) => { | |
| if (!colorMenu) return; | |
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | |
| // Update local state to reflect change immediately | |
| setColorMenu((prev) => { | |
| if (!prev) return null; | |
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | |
| return { | |
| ...prev, | |
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | |
| }; | |
| }); | |
| }, | |
| [colorMenu, setSessionSlotFollowAction], | |
| ); |
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | ||
| return { | ||
| ...prev, | ||
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | ||
| }; |
There was a problem hiding this comment.
The default follow-action config is duplicated here (defaultFA) and in setSessionSlotFollowAction in the store. Keeping two sources of truth risks them drifting (e.g., if defaults change later, the UI and store could disagree). Consider exporting a shared DEFAULT_FOLLOW_ACTION_CONFIG constant (or a small helper) and reusing it in both places.
| @@ -633,11 +652,13 @@ export interface SessionClipSlot { | |||
| color?: string | null; | |||
| /** When true (default), an empty slot acts as a stop button for the track. */ | |||
| hasStopButton?: boolean; | |||
| /** Follow action configuration for this clip slot. */ | |||
| followAction?: FollowActionConfig; | |||
There was a problem hiding this comment.
PR title/description mention follow actions for “session clips and scenes”, but the types added here only support per-clip-slot follow actions (SessionClipSlot.followAction) and a global toggle—there’s no scene-level follow-action config on SessionScene or elsewhere. If scene follow actions are in scope for this PR (and to fully close #920), the project types/store will need a scene follow-action representation and execution path.
| stopSessionTrack: (trackId: string) => void; | ||
| stopAllSessionClips: () => void; | ||
| commitPendingSessionLaunches: (currentTime: number) => void; | ||
| setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void; |
There was a problem hiding this comment.
In ProjectState, setSessionSlotFollowAction uses Partial<import('../types/project').FollowActionConfig> even though FollowActionConfig is already imported in this file. Using the imported type directly keeps the signature consistent/readable and avoids an unnecessary inline import type.
| setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void; | |
| setSessionSlotFollowAction: (slotId: string, config: Partial<FollowActionConfig>) => void; |
|
Triage: Hold for Phase 2 — Session view follow actions are Ableton-parity features. Not blocking for core DAW usability. Defer until session view foundation is complete. Generated by Claude Code |
Summary
Test plan
npx tsc --noEmitpasses with 0 errorsnpm test— all 2784 tests passnpm run build— succeedsCloses #920
🤖 Generated with Claude Code