Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
127 changes: 126 additions & 1 deletion src/components/session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useSessionDragDrop, type SessionDragState, type SessionDropTarget } fro
import { ContextMenuWrapper, ContextMenuSeparator, ContextMenuItem } from '../ui/ContextMenu';
import { ColorSwatchPalette } from '../ui/ColorSwatchPalette';
import { SessionMixer } from './SessionMixer';
import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType } from '../../types/project';
import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType, FollowActionType, FollowActionConfig } from '../../types/project';

const LAUNCH_MODE_OPTIONS: SessionLaunchMode[] = ['trigger', 'gate', 'toggle', 'repeat'];

Expand Down Expand Up @@ -80,8 +80,20 @@ interface SlotContextMenuState {
currentColor: string | null;
legato: boolean;
currentLaunchMode: SessionLaunchMode;
followAction?: FollowActionConfig;
}

const CLIP_FOLLOW_ACTION_OPTIONS: { value: FollowActionType; label: string }[] = [
{ value: 'stop', label: 'Stop' },
{ value: 'again', label: 'Again' },
{ value: 'previous', label: 'Previous' },
{ value: 'next', label: 'Next' },
{ value: 'first', label: 'First' },
{ value: 'last', label: 'Last' },
{ value: 'any', label: 'Any' },
{ value: 'other', label: 'Other' },
];


export function SessionView() {
const project = useProjectStore((s) => s.project);
Expand All @@ -94,6 +106,8 @@ export function SessionView() {
const setSessionSlotColor = useProjectStore((s) => s.setSessionSlotColor);
const setSessionSlotLegato = useProjectStore((s) => s.setSessionSlotLegato);
const setSessionSlotLaunchMode = useProjectStore((s) => s.setSessionSlotLaunchMode);
const setSessionSlotFollowAction = useProjectStore((s) => s.setSessionSlotFollowAction);
const setSessionFollowActionsEnabled = useProjectStore((s) => s.setSessionFollowActionsEnabled);
const selectedSessionSlot = useUIStore((s) => s.selectedSessionSlot);
const setSelectedSessionSlot = useUIStore((s) => s.setSelectedSessionSlot);
const setKeyboardContext = useUIStore((s) => s.setKeyboardContext);
Expand Down Expand Up @@ -135,6 +149,20 @@ export function SessionView() {
}
}, [colorMenu, setSessionSlotLaunchMode]);

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 },
};
Comment on lines +158 to +162
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
});
}, [colorMenu, setSessionSlotFollowAction]);
Comment on lines +152 to +164
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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],
);

Copilot uses AI. Check for mistakes.

// Set keyboard context to 'session' on mount, restore previous on unmount
useEffect(() => {
const previousScope = useUIStore.getState().keyboardContext.scope;
Expand Down Expand Up @@ -166,6 +194,7 @@ export function SessionView() {
const sessionSlots = project.session?.slots ?? [];
const pendingLaunches = project.session?.pendingLaunches ?? [];
const scenes = project.session?.scenes ?? [];
const followActionsEnabled = project.session?.followActionsEnabled !== false;

return (
<div className="flex-1 min-w-0 bg-[radial-gradient(circle_at_top,#313131_0%,#202020_55%,#171717_100%)] border-l border-[#111] overflow-auto">
Expand All @@ -190,6 +219,17 @@ export function SessionView() {
))}
</select>
</label>
<button
onClick={() => setSessionFollowActionsEnabled(!followActionsEnabled)}
className={`px-3 py-1.5 rounded-md text-[11px] font-medium transition-colors ${
followActionsEnabled
? 'bg-purple-600/30 text-purple-300 border border-purple-500/50'
: 'bg-[#2a2a2a] text-zinc-500 hover:bg-[#343434]'
}`}
aria-label={followActionsEnabled ? 'Disable follow actions' : 'Enable follow actions'}
>
Follow Actions {followActionsEnabled ? 'ON' : 'OFF'}
</button>
<button
onClick={() => void toggleSessionArrangementRecording()}
className={`px-3 py-1.5 rounded-md text-[11px] font-medium transition-colors ${
Expand Down Expand Up @@ -372,6 +412,81 @@ export function SessionView() {
setColorMenu(null);
}}
/>
<ContextMenuSeparator />
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
Follow Action
</div>
<div className="px-3 py-1 flex items-center gap-2">
<label className="flex items-center gap-1.5 text-[11px] text-zinc-300 cursor-pointer">
<input
type="checkbox"
checked={colorMenu.followAction?.enabled ?? false}
onChange={(e) => handleFollowActionChange('enabled', e.target.checked)}
className="accent-purple-500"
aria-label="Enable follow action for this slot"
/>
Enabled
</label>
</div>
{(colorMenu.followAction?.enabled) && (
<>
<div className="px-3 py-1 flex items-center gap-2">
<label className="text-[11px] text-zinc-400 w-10">A:</label>
<select
value={colorMenu.followAction?.actionA ?? 'next'}
onChange={(e) => handleFollowActionChange('actionA', e.target.value)}
className="flex-1 rounded bg-[#2a2a2a] border border-[#444] px-1.5 py-0.5 text-[11px] text-zinc-200 outline-none"
aria-label="Follow action A"
>
{CLIP_FOLLOW_ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="px-3 py-1 flex items-center gap-2">
<label className="text-[11px] text-zinc-400 w-10">B:</label>
<select
value={colorMenu.followAction?.actionB ?? 'stop'}
onChange={(e) => handleFollowActionChange('actionB', e.target.value)}
className="flex-1 rounded bg-[#2a2a2a] border border-[#444] px-1.5 py-0.5 text-[11px] text-zinc-200 outline-none"
aria-label="Follow action B"
>
{CLIP_FOLLOW_ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="px-3 py-1 flex items-center gap-2">
<label className="text-[11px] text-zinc-400 w-10">A%:</label>
<input
type="range"
min={0}
max={100}
value={Math.round((colorMenu.followAction?.chanceA ?? 1) * 100)}
onChange={(e) => handleFollowActionChange('chanceA', Number(e.target.value) / 100)}
className="flex-1 accent-purple-500"
aria-label="Follow action A probability"
/>
<span className="text-[10px] text-zinc-400 w-8 text-right">
{Math.round((colorMenu.followAction?.chanceA ?? 1) * 100)}%
</span>
</div>
<div className="px-3 py-1 flex items-center gap-2">
<label className="text-[11px] text-zinc-400 w-10">Time:</label>
<input
type="number"
min={0.25}
max={64}
step={0.25}
value={colorMenu.followAction?.time ?? 4}
onChange={(e) => handleFollowActionChange('time', Number(e.target.value))}
className="w-16 rounded bg-[#2a2a2a] border border-[#444] px-1.5 py-0.5 text-[11px] text-zinc-200 outline-none"
aria-label="Follow action time in beats"
/>
<span className="text-[10px] text-zinc-400">beats</span>
</div>
</>
)}
</ContextMenuWrapper>
)}

Expand Down Expand Up @@ -651,6 +766,7 @@ function FragmentRow({
currentColor: slotColor,
legato: slot.legato ?? false,
currentLaunchMode: slotLaunchMode,
followAction: slot.followAction,
});
};

Expand Down Expand Up @@ -760,6 +876,15 @@ function FragmentRow({
{launchModeBadge}
</span>
)}
{slot?.followAction?.enabled && (
<span
className="absolute top-1 left-6 rounded bg-purple-600/80 px-1 py-0.5 text-[9px] font-semibold text-white leading-none pointer-events-none"
title={`Follow: ${slot.followAction.actionA} / ${slot.followAction.actionB}`}
data-testid={`follow-badge-${slot.id}`}
>
&#x2192;
</span>
)}
{hasOverride && (
<span
className="absolute top-1 right-1 rounded bg-daw-accent/80 px-1 py-0.5 text-[9px] font-semibold text-white leading-none pointer-events-none"
Expand Down
104 changes: 104 additions & 0 deletions src/store/projectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
DrumMachineConfig,
DrumKitName,
SamplerConfig,
FollowActionConfig,
SessionClipSlot,
SessionLaunchEvent,
SessionLaunchMode,
Expand Down Expand Up @@ -114,6 +115,7 @@ import { encodeMidiFile, parseMidiFile } from '../utils/midi';
import { encodeMidiFile as encodeMultiTrackMidiFile, type MidiExportTrack } from '../utils/midiEncoder';
import { clampClipFadeDurations } from '../utils/clipFade';
import { getSynthPresetById } from '../data/synthPresets';
import { detectClipGroups, resolveFollowAction, rollFollowAction } from '../utils/followActions';
import { extractGroove, applyGroove, type ExtractGrooveOptions, type ApplyGrooveOptions } from '../utils/groovePool';
import type { GrooveTemplate } from '../types/project';
import { toastError, toastSuccess } from '../hooks/useToast';
Expand Down Expand Up @@ -723,6 +725,9 @@ export interface ProjectState extends MidiSliceActions {
stopSessionTrack: (trackId: string) => void;
stopAllSessionClips: () => void;
commitPendingSessionLaunches: (currentTime: number) => void;
setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void;
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void;
setSessionSlotFollowAction: (slotId: string, config: Partial<FollowActionConfig>) => void;

Copilot uses AI. Check for mistakes.
setSessionFollowActionsEnabled: (enabled: boolean) => void;
scheduleFollowAction: (trackId: string, currentSlotId: string, launchTime: number) => void;
startSessionArrangementRecording: (startTime?: number) => void;
stopSessionArrangementRecording: (endTime?: number) => Clip[];
moveSessionSlotClip: (sourceSlotId: string, targetSlotId: string) => void;
Expand Down Expand Up @@ -5200,6 +5205,15 @@ export const useProjectStore = create<ProjectState>()(
nextProject = applySessionTrackLaunch(nextProject, launch.trackId, null, launch.executeAt, 'stop');
continue;
}
if (launch.type === 'follow-action' && launch.trackId) {
if (launch.clipId) {
nextProject = applySessionTrackLaunch(nextProject, launch.trackId, launch.clipId, launch.executeAt, 'clip', launch.sceneId ?? null);
} else {
// follow-action resolved to 'stop'
nextProject = applySessionTrackLaunch(nextProject, launch.trackId, null, launch.executeAt, 'stop');
}
continue;
}
if (launch.type === 'stop-all') {
for (const track of nextProject.tracks) {
nextProject = applySessionTrackLaunch(nextProject, track.id, null, launch.executeAt, 'stop');
Expand All @@ -5210,6 +5224,96 @@ export const useProjectStore = create<ProjectState>()(
set({ project: nextProject });
},

setSessionSlotFollowAction: (slotId, config) => {
const state = get();
if (!state.project) return;
const session = ensureProjectSession(state.project).session!;
const slotIndex = session.slots.findIndex((s) => s.id === slotId);
if (slotIndex === -1) return;

_pushHistory(state.project);
const currentSlot = session.slots[slotIndex];
const defaultConfig: FollowActionConfig = {
actionA: 'next',
actionB: 'stop',
chanceA: 1,
time: 4,
enabled: true,
};
const merged: FollowActionConfig = {
...(currentSlot.followAction ?? defaultConfig),
...config,
};

const nextSlots = [...session.slots];
nextSlots[slotIndex] = { ...currentSlot, followAction: merged };
set({
project: {
...state.project,
updatedAt: Date.now(),
session: { ...session, slots: nextSlots },
},
});
},

setSessionFollowActionsEnabled: (enabled) => {
const state = get();
if (!state.project) return;
const session = ensureProjectSession(state.project).session!;

_pushHistory(state.project);
set({
project: {
...state.project,
updatedAt: Date.now(),
session: { ...session, followActionsEnabled: enabled },
},
});
},

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;
Comment on lines +5274 to +5290
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

// 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,
});

Comment on lines +5274 to +5308
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
set({
project: {
...state.project,
session: nextSession,
},
});
},

startSessionArrangementRecording: (startTime) => {
const state = get();
if (!state.project) return;
Expand Down
25 changes: 24 additions & 1 deletion src/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,25 @@ export type SessionLaunchMode = 'trigger' | 'gate' | 'toggle' | 'repeat';
/** Action to perform automatically when a scene finishes playing. */
export type SceneFollowActionType = 'none' | 'next' | 'previous' | 'random' | 'stop';

// ─── Follow Action Types ─────────────────────────────────────────────────────

/** The type of follow action to perform when a clip finishes playing. */
export type FollowActionType = 'stop' | 'again' | 'previous' | 'next' | 'first' | 'last' | 'any' | 'other';

/** Configuration for a follow action on a session clip slot. */
export interface FollowActionConfig {
/** Primary follow action. */
actionA: FollowActionType;
/** Secondary follow action. */
actionB: FollowActionType;
/** Probability of action A (0-1). Action B probability = 1 - chanceA. */
chanceA: number;
/** Follow action trigger time in beats (e.g., 4 = 1 bar in 4/4). */
time: number;
/** Whether this follow action is enabled. */
enabled: boolean;
}

export interface SessionScene {
id: string;
name: string;
Expand Down Expand Up @@ -990,11 +1009,13 @@ export interface SessionClipSlot {
legato?: boolean;
/** Clip launch behavior: trigger (default), gate, toggle, or repeat. */
launchMode?: SessionLaunchMode;
/** Follow action configuration for this clip slot. */
followAction?: FollowActionConfig;
Comment on lines 964 to +1013
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
}

export interface SessionPendingLaunch {
id: string;
type: 'clip' | 'scene' | 'stop-track' | 'stop-all';
type: 'clip' | 'scene' | 'stop-track' | 'stop-all' | 'follow-action';
executeAt: number;
requestedAt: number;
trackId?: string;
Expand Down Expand Up @@ -1024,6 +1045,8 @@ export interface SessionState {
recordedLaunches: SessionLaunchEvent[];
lastLaunchedSceneId: string | null;
lastLaunchAt: number | null;
/** Global toggle for follow actions. When false, no follow actions fire. Default true. */
followActionsEnabled?: boolean;
}

/** A saved project template — a snapshot of project settings and track layout (without audio). */
Expand Down
Loading
Loading