diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx
index a86a703097..e720540954 100644
--- a/assets/js/collaborative-editor/CollaborativeEditor.tsx
+++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx
@@ -2,12 +2,12 @@ import { useMemo, useRef } from 'react';
import { useURLState } from '#/react/lib/use-url-state';
+import { PickerButton } from '../picker/PickerButton';
import { SocketProvider } from '../react/contexts/SocketProvider';
import type { WithActionProps } from '../react/lib/with-props';
import { AIAssistantPanelWrapper } from './components/AIAssistantPanelWrapper';
import { BreadcrumbLink, BreadcrumbText } from './components/Breadcrumbs';
-import { PickerButton } from '../picker/PickerButton';
import type { MonacoHandle } from './components/CollaborativeMonaco';
import { Header } from './components/Header';
import { LoadingBoundary } from './components/LoadingBoundary';
@@ -163,6 +163,7 @@ function BreadcrumbContent({
key="canvas-header"
{...(projectId !== undefined && { projectId })}
workflowId={workflowId}
+ isSandbox={isSandbox}
isRunPanelOpen={isRunPanelOpen}
isIDEOpen={isIDEOpen}
aiAssistantEnabled={aiAssistantEnabled}
diff --git a/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx b/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx
new file mode 100644
index 0000000000..6e9516d791
--- /dev/null
+++ b/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx
@@ -0,0 +1,346 @@
+/**
+ * # Edit in sandbox picker
+ *
+ * Modal shown from a live workflow on a non-sandbox project. It offers two
+ * ways to start editing in a sandbox:
+ *
+ * 1. Create a new sandbox branched from the current live version.
+ * 2. Join an existing active sandbox.
+ *
+ * A sandbox is a separate project, so both paths hard-navigate to the sandbox
+ * project's editor (a new project means a new Y.Doc session), consistent with
+ * the legacy-editor switch. The list is fetched when the dialog opens and is
+ * rendered in the order returned by the server (last-edited first).
+ */
+
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from '@headlessui/react';
+import { formatDistanceToNow } from 'date-fns';
+import { useCallback, useEffect, useState } from 'react';
+import { toast } from 'sonner';
+
+import { cn } from '../../utils/cn';
+import { useWorkflowActions } from '../hooks/useWorkflow';
+import { ChannelRequestError } from '../lib/errors';
+import type { Sandbox, SandboxCollaborator } from '../types/workflow';
+
+interface EditInSandboxPickerProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+/**
+ * The server returns a human-readable reason in `errors.base` (a usage-limit
+ * upsell, a permission message, etc.). Surface it rather than a generic toast.
+ */
+function serverMessage(error: unknown): string | null {
+ if (error instanceof ChannelRequestError) {
+ return error.errors['base']?.[0] ?? null;
+ }
+ return null;
+}
+
+function collaboratorInitials(collaborator: SandboxCollaborator): string {
+ const source = collaborator.name?.trim() || collaborator.email?.trim() || '';
+ if (!source) return '?';
+
+ const parts = source.split(/\s+/).filter(Boolean);
+ if (parts.length >= 2 && parts[0] && parts[1]) {
+ return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
+ }
+ return source.slice(0, 2).toUpperCase();
+}
+
+function CollaboratorAvatars({
+ collaborators,
+}: {
+ collaborators: SandboxCollaborator[];
+}) {
+ if (collaborators.length === 0) return null;
+
+ const visible = collaborators.slice(0, 4);
+ const overflow = collaborators.length - visible.length;
+
+ return (
+
+ {visible.map(collaborator => (
+
+ {collaboratorInitials(collaborator)}
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
+
+function formatUpdatedAt(updatedAt: string): string {
+ const date = new Date(updatedAt);
+ if (Number.isNaN(date.getTime())) return '';
+ return `edited ${formatDistanceToNow(date, { addSuffix: true })}`;
+}
+
+const navigateToSandbox = (projectId: string, workflowId: string) => {
+ window.location.href = `/projects/${projectId}/w/${workflowId}`;
+};
+
+export function EditInSandboxPicker({
+ isOpen,
+ onClose,
+}: EditInSandboxPickerProps) {
+ const { listSandboxes, editInSandbox } = useWorkflowActions();
+
+ const [name, setName] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
+ const [isLoadingList, setIsLoadingList] = useState(false);
+ const [sandboxes, setSandboxes] = useState([]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ let cancelled = false;
+ setIsLoadingList(true);
+ setSandboxes([]);
+
+ const load = async () => {
+ try {
+ const result = await listSandboxes();
+ if (!cancelled) setSandboxes(result);
+ } catch (error) {
+ if (!cancelled) {
+ toast.error(
+ serverMessage(error) ??
+ 'Could not load sandboxes. Please try again.'
+ );
+ }
+ } finally {
+ if (!cancelled) setIsLoadingList(false);
+ }
+ };
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isOpen, listSandboxes]);
+
+ const handleCreate = useCallback(() => {
+ setIsCreating(true);
+ const trimmed = name.trim();
+
+ const create = async () => {
+ try {
+ const { project_id, workflow_id } = await editInSandbox(
+ trimmed || undefined
+ );
+ navigateToSandbox(project_id, workflow_id);
+ } catch (error) {
+ toast.error(
+ serverMessage(error) ??
+ 'Could not create a sandbox. Please try again.'
+ );
+ setIsCreating(false);
+ }
+ };
+
+ void create();
+ }, [name, editInSandbox]);
+
+ const handleJoin = useCallback((sandbox: Sandbox) => {
+ if (!sandbox.workflow_id) return;
+ navigateToSandbox(sandbox.id, sandbox.workflow_id);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx
index d42344841f..af8a7e65c0 100644
--- a/assets/js/collaborative-editor/components/Header.tsx
+++ b/assets/js/collaborative-editor/components/Header.tsx
@@ -4,11 +4,12 @@ import { toast } from 'sonner';
import { useURLState } from '#/react/lib/use-url-state';
+import { Tooltip } from '../../components/Tooltip';
+import { cn } from '../../utils/cn';
import { buildClassicalEditorUrl } from '../../utils/editorUrlConversion';
import * as dataclipApi from '../api/dataclips';
import { StoreContext } from '../contexts/StoreProvider';
import { channelRequest } from '../hooks/useChannel';
-import { getCsrfToken } from '../lib/csrf';
import { useActiveRun } from '../hooks/useHistory';
import { useSession } from '../hooks/useSession';
import {
@@ -34,6 +35,7 @@ import {
useWorkflowState,
} from '../hooks/useWorkflow';
import { useKeyboardShortcut } from '../keyboard';
+import { getCsrfToken } from '../lib/csrf';
import { notifications } from '../lib/notifications';
import { isFinalState } from '../types/history';
@@ -41,12 +43,12 @@ import { ActiveCollaborators } from './ActiveCollaborators';
import { AIButton } from './AIButton';
import { AlertDialog } from './AlertDialog';
import { Breadcrumbs } from './Breadcrumbs';
+import { EditInSandboxPicker } from './EditInSandboxPicker';
import { EmailVerificationBanner } from './EmailVerificationBanner';
import { GitHubSyncModal } from './GitHubSyncModal';
import { NewRunButton } from './NewRunButton';
import { ReadOnlyWarning } from './ReadOnlyWarning';
import { ShortcutKeys } from './ShortcutKeys';
-import { Tooltip } from '../../components/Tooltip';
/**
* Save button component - visible in React DevTools
@@ -202,6 +204,7 @@ export function Header({
children,
projectId,
workflowId,
+ isSandbox = false,
isRunPanelOpen = false,
isIDEOpen = false,
aiAssistantEnabled = false,
@@ -209,6 +212,7 @@ export function Header({
children: React.ReactNode[];
projectId?: string;
workflowId?: string;
+ isSandbox?: boolean;
isRunPanelOpen?: boolean;
isIDEOpen?: boolean;
aiAssistantEnabled?: boolean;
@@ -239,6 +243,7 @@ export function Header({
const lifecycleState = sessionWorkflow?.state;
const [isTransitioning, setIsTransitioning] = useState(false);
const [showSwitchToDraftDialog, setShowSwitchToDraftDialog] = useState(false);
+ const [showEditInSandboxPicker, setShowEditInSandboxPicker] = useState(false);
const activeRun = useActiveRun();
const runIsProcessing = activeRun ? !isFinalState(activeRun.state) : false;
const followedRunId = params.run ?? null;
@@ -521,6 +526,15 @@ export function Header({
+ {isSandbox && (
+
+
+ sandbox
+
+ )}
{lifecycleState && !isNewWorkflow && (
)}
+ {lifecycleState === 'live' && !isSandbox && !isNewWorkflow && (
+
+ )}
{projectId &&
workflowId &&
firstTriggerId &&
@@ -662,6 +691,13 @@ export function Header({
confirmLabel="Switch to draft"
variant="danger"
/>
+
+ {
+ setShowEditInSandboxPicker(false);
+ }}
+ />
>
diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx
index 909d4c8649..9701229df0 100644
--- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx
+++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx
@@ -674,6 +674,12 @@ export const useWorkflowActions = () => {
return wrappedSaveAndSyncWorkflow();
},
+ // Sandbox editing. listSandboxes is a query (fetch candidates) and
+ // editInSandbox is a command (provision + clone). Navigation lives in the
+ // calling component, consistent with the legacy-editor switch.
+ listSandboxes: store.listSandboxes,
+ editInSandbox: store.editInSandbox,
+
resetWorkflow: store.resetWorkflow,
importWorkflow: store.importWorkflow,
diff --git a/assets/js/collaborative-editor/stores/createWorkflowStore.ts b/assets/js/collaborative-editor/stores/createWorkflowStore.ts
index e6fcb16c91..8522950d46 100644
--- a/assets/js/collaborative-editor/stores/createWorkflowStore.ts
+++ b/assets/js/collaborative-editor/stores/createWorkflowStore.ts
@@ -144,7 +144,7 @@ import { notifications } from '../lib/notifications';
import { EdgeSchema } from '../types/edge';
import { JobSchema } from '../types/job';
import type { Session } from '../types/session';
-import type { BaseWorkflow, Workflow } from '../types/workflow';
+import type { BaseWorkflow, Sandbox, Workflow } from '../types/workflow';
import { getIncomingEdgeIndices } from '../utils/workflowGraph';
import { createWithSelector } from './common';
@@ -1452,6 +1452,46 @@ export const createWorkflowStore = () => {
const goLive = async () => setLifecycleState('go_live');
const switchToDraft = async () => setLifecycleState('switch_to_draft');
+ // Sandbox editing. From a live workflow on a non-sandbox project, a user can
+ // either branch the current live version into a freshly provisioned sandbox
+ // or join an existing sandbox. The server owns provisioning and cloning; the
+ // client only lists candidates and requests creation, then hard-navigates
+ // into the resulting sandbox project (a new project = a new Y.Doc session).
+ const listSandboxes = async (): Promise => {
+ const { provider } = ensureConnected();
+
+ try {
+ const response = await channelRequest<{ sandboxes: Sandbox[] }>(
+ provider.channel,
+ 'list_sandboxes',
+ {}
+ );
+ return response.sandboxes;
+ } catch (error) {
+ logger.error('Failed to list sandboxes', error);
+ throw error;
+ }
+ };
+
+ const editInSandbox = async (
+ name?: string
+ ): Promise<{ project_id: string; workflow_id: string }> => {
+ const { provider } = ensureConnected();
+
+ const payload = name ? { name } : {};
+
+ try {
+ return await channelRequest<{ project_id: string; workflow_id: string }>(
+ provider.channel,
+ 'edit_in_sandbox',
+ payload
+ );
+ } catch (error) {
+ logger.error('Failed to edit in sandbox', error);
+ throw error;
+ }
+ };
+
const saveAndSyncWorkflow = async (
commitMessage: string
): Promise<{
@@ -1948,6 +1988,8 @@ export const createWorkflowStore = () => {
saveWorkflow,
goLive,
switchToDraft,
+ listSandboxes,
+ editInSandbox,
saveAndSyncWorkflow,
resetWorkflow,
validateWorkflowName,
diff --git a/assets/js/collaborative-editor/types/workflow.ts b/assets/js/collaborative-editor/types/workflow.ts
index ce27994be6..4ad7f53392 100644
--- a/assets/js/collaborative-editor/types/workflow.ts
+++ b/assets/js/collaborative-editor/types/workflow.ts
@@ -55,6 +55,26 @@ export const BaseWorkflowSchema = z.object({
export type BaseWorkflow = z.infer;
+/**
+ * A sandbox project that can be joined or branched from when editing a live
+ * workflow. `workflow_id` is the id of this workflow's clone inside the
+ * sandbox, and is null when the workflow does not exist in that sandbox.
+ */
+export interface SandboxCollaborator {
+ id: string;
+ name?: string;
+ email?: string;
+}
+
+export interface Sandbox {
+ id: string;
+ name: string;
+ color: string | null;
+ updated_at: string;
+ collaborators: SandboxCollaborator[];
+ workflow_id: string | null;
+}
+
/**
* Creates a workflow schema with dynamic project concurrency validation
*
diff --git a/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx b/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx
new file mode 100644
index 0000000000..0774ba5a83
--- /dev/null
+++ b/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx
@@ -0,0 +1,158 @@
+/**
+ * EditInSandboxPicker Component Tests
+ *
+ * Focused tests for the sandbox picker modal:
+ * - Renders the "create new sandbox" option.
+ * - Lists active sandboxes returned by the server (in order).
+ * - Disables the join action when a sandbox lacks this workflow's clone.
+ * - Creating a sandbox navigates to the new project's editor.
+ *
+ * The picker's only collaboration dependency is useWorkflowActions, which we
+ * mock so we can drive listSandboxes/editInSandbox deterministically.
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { EditInSandboxPicker } from '../../../js/collaborative-editor/components/EditInSandboxPicker';
+import type { Sandbox } from '../../../js/collaborative-editor/types/workflow';
+
+const listSandboxes = vi.fn<() => Promise>();
+const editInSandbox =
+ vi.fn<
+ (name?: string) => Promise<{ project_id: string; workflow_id: string }>
+ >();
+
+vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({
+ useWorkflowActions: () => ({ listSandboxes, editInSandbox }),
+}));
+
+const sandboxes: Sandbox[] = [
+ {
+ id: 'sandbox-a',
+ name: 'Alpha sandbox',
+ color: null,
+ updated_at: new Date().toISOString(),
+ collaborators: [
+ { id: 'u1', name: 'Ada Lovelace' },
+ { id: 'u2', email: 'grace@example.com' },
+ ],
+ workflow_id: 'wf-clone-a',
+ },
+ {
+ id: 'sandbox-b',
+ name: 'Beta sandbox',
+ color: '#ff0000',
+ updated_at: new Date().toISOString(),
+ collaborators: [],
+ workflow_id: null,
+ },
+];
+
+describe('EditInSandboxPicker', () => {
+ beforeEach(() => {
+ listSandboxes.mockReset();
+ editInSandbox.mockReset();
+ listSandboxes.mockResolvedValue([]);
+ });
+
+ test('renders the create option and fetches sandboxes on open', async () => {
+ listSandboxes.mockResolvedValue([]);
+
+ render( {}} />);
+
+ expect(screen.getByText('Create a new sandbox')).toBeInTheDocument();
+ expect(screen.getByTestId('create-sandbox-button')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(listSandboxes).toHaveBeenCalledTimes(1);
+ });
+
+ // Empty state when no sandboxes exist
+ await waitFor(() => {
+ expect(screen.getByTestId('sandbox-list-empty')).toBeInTheDocument();
+ });
+ });
+
+ test('does not fetch when closed', () => {
+ render( {}} />);
+ expect(listSandboxes).not.toHaveBeenCalled();
+ });
+
+ test('lists active sandboxes in returned order', async () => {
+ listSandboxes.mockResolvedValue(sandboxes);
+
+ render( {}} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sandbox-list')).toBeInTheDocument();
+ });
+
+ const rows = screen.getAllByTestId('sandbox-row');
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toHaveTextContent('Alpha sandbox');
+ expect(rows[1]).toHaveTextContent('Beta sandbox');
+ });
+
+ test('disables join when the workflow is not in that sandbox', async () => {
+ listSandboxes.mockResolvedValue(sandboxes);
+
+ render( {}} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sandbox-list')).toBeInTheDocument();
+ });
+
+ const joinButtons = screen.getAllByTestId('join-sandbox-button');
+ // First sandbox has a workflow clone -> enabled
+ expect(joinButtons[0]).toBeEnabled();
+ // Second sandbox has workflow_id null -> disabled with explanation
+ expect(joinButtons[1]).toBeDisabled();
+ expect(joinButtons[1]).toHaveAttribute(
+ 'title',
+ "This workflow isn't in that sandbox"
+ );
+ });
+
+ test('creating a sandbox navigates to the new project editor', async () => {
+ const user = userEvent.setup();
+ listSandboxes.mockResolvedValue([]);
+ editInSandbox.mockResolvedValue({
+ project_id: 'new-project',
+ workflow_id: 'new-workflow',
+ });
+
+ const originalLocation = window.location;
+ const hrefSetter = vi.fn();
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ ...originalLocation,
+ set href(value: string) {
+ hrefSetter(value);
+ },
+ },
+ });
+
+ try {
+ render( {}} />);
+
+ await user.click(screen.getByTestId('create-sandbox-button'));
+
+ await waitFor(() => {
+ expect(editInSandbox).toHaveBeenCalledTimes(1);
+ });
+ await waitFor(() => {
+ expect(hrefSetter).toHaveBeenCalledWith(
+ '/projects/new-project/w/new-workflow'
+ );
+ });
+ } finally {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ });
+ }
+ });
+});
diff --git a/assets/test/collaborative-editor/components/Header.sandbox.test.tsx b/assets/test/collaborative-editor/components/Header.sandbox.test.tsx
new file mode 100644
index 0000000000..1cad02d8af
--- /dev/null
+++ b/assets/test/collaborative-editor/components/Header.sandbox.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * Header sandbox-affordance tests
+ *
+ * Focused on the gating rules introduced for "Edit in sandbox":
+ * - The "Edit in sandbox" button appears only when the workflow is live,
+ * the current project is not itself a sandbox, and the workflow is saved
+ * (not new).
+ * - The sandbox badge appears when editing inside a sandbox.
+ *
+ * The Header pulls in many collaboration hooks and child components; those are
+ * mocked to neutral defaults so each test exercises only the gating logic.
+ */
+
+import { render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { Header } from '../../../js/collaborative-editor/components/Header';
+
+// ---------------------------------------------------------------------------
+// Hook + child-component mocks
+// ---------------------------------------------------------------------------
+
+let lifecycleState: 'draft' | 'live' | undefined = 'live';
+let isNewWorkflow = false;
+
+vi.mock('../../../js/react/lib/use-url-state', () => ({
+ useURLState: () => ({ params: {}, updateSearchParams: vi.fn() }),
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useHistory', () => ({
+ useActiveRun: () => null,
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useSession', () => ({
+ useSession: () => ({ provider: null, isSynced: true }),
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useSessionContext', () => ({
+ useIsNewWorkflow: () => isNewWorkflow,
+ useLimits: () => ({}),
+ useProjectRepoConnection: () => null,
+ useSessionWorkflow: () => ({ state: lifecycleState }),
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({
+ useImportPanelState: () => null,
+ useIsCreateWorkflowPanelCollapsed: () => true,
+ useTemplatePanel: () => ({ selectedTemplate: null }),
+ useUICommands: () => ({
+ openRunPanel: vi.fn(),
+ openGitHubSyncModal: vi.fn(),
+ }),
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useUnsavedChanges', () => ({
+ useUnsavedChanges: () => ({ hasChanges: false }),
+}));
+
+vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({
+ useCanRun: () => ({ canRun: true }),
+ useCanSave: () => ({ canSave: true, tooltipMessage: '' }),
+ useNodeSelection: () => ({ selectNode: vi.fn() }),
+ useWorkflowActions: () => ({
+ saveWorkflow: vi.fn(),
+ goLive: vi.fn(),
+ switchToDraft: vi.fn(),
+ listSandboxes: vi.fn(),
+ editInSandbox: vi.fn(),
+ }),
+ useWorkflowReadOnly: () => ({ isReadOnly: false }),
+ useWorkflowSettingsErrors: () => ({ hasErrors: false }),
+ useWorkflowState: (selector: (state: unknown) => unknown) =>
+ selector({ triggers: [], jobs: [] }),
+}));
+
+vi.mock('../../../js/collaborative-editor/keyboard', () => ({
+ useKeyboardShortcut: vi.fn(),
+}));
+
+// Header reads StoreContext via useContext with optional chaining, so leaving
+// it unprovided (undefined) is handled gracefully and avoids extra wiring.
+
+// Child components rendered by Header that are irrelevant to the gating logic.
+vi.mock(
+ '../../../js/collaborative-editor/components/ActiveCollaborators',
+ () => ({
+ ActiveCollaborators: () => ,
+ })
+);
+vi.mock('../../../js/collaborative-editor/components/AIButton', () => ({
+ AIButton: () => ,
+}));
+vi.mock(
+ '../../../js/collaborative-editor/components/EmailVerificationBanner',
+ () => ({ EmailVerificationBanner: () => })
+);
+vi.mock('../../../js/collaborative-editor/components/GitHubSyncModal', () => ({
+ GitHubSyncModal: () => ,
+}));
+vi.mock('../../../js/collaborative-editor/components/NewRunButton', () => ({
+ NewRunButton: () => ,
+}));
+vi.mock('../../../js/collaborative-editor/components/ReadOnlyWarning', () => ({
+ ReadOnlyWarning: () => ,
+}));
+vi.mock(
+ '../../../js/collaborative-editor/components/EditInSandboxPicker',
+ () => ({
+ EditInSandboxPicker: () => ,
+ })
+);
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const renderHeader = (
+ props: Partial> = {}
+) =>
+ render(
+
+ );
+
+describe('Header - Edit in sandbox button gating', () => {
+ beforeEach(() => {
+ lifecycleState = 'live';
+ isNewWorkflow = false;
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('shows the button for a live, saved workflow on a non-sandbox project', () => {
+ renderHeader({ isSandbox: false });
+ expect(screen.getByTestId('edit-in-sandbox-button')).toBeInTheDocument();
+ });
+
+ test('hides the button when the workflow is in draft', () => {
+ lifecycleState = 'draft';
+ renderHeader({ isSandbox: false });
+ expect(
+ screen.queryByTestId('edit-in-sandbox-button')
+ ).not.toBeInTheDocument();
+ });
+
+ test('hides the button when already inside a sandbox', () => {
+ renderHeader({ isSandbox: true });
+ expect(
+ screen.queryByTestId('edit-in-sandbox-button')
+ ).not.toBeInTheDocument();
+ });
+
+ test('hides the button for a new (unsaved) workflow', () => {
+ isNewWorkflow = true;
+ renderHeader({ isSandbox: false });
+ expect(
+ screen.queryByTestId('edit-in-sandbox-button')
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('Header - sandbox badge', () => {
+ beforeEach(() => {
+ lifecycleState = 'live';
+ isNewWorkflow = false;
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('renders the sandbox badge when editing inside a sandbox', () => {
+ renderHeader({ isSandbox: true });
+ const badge = screen.getByTestId('workflow-sandbox-badge');
+ expect(badge).toBeInTheDocument();
+ expect(badge).toHaveTextContent('sandbox');
+ });
+
+ test('does not render the sandbox badge outside a sandbox', () => {
+ renderHeader({ isSandbox: false });
+ expect(
+ screen.queryByTestId('workflow-sandbox-badge')
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex
index f23e74dba1..25cb0f0ea9 100644
--- a/lib/lightning/projects.ex
+++ b/lib/lightning/projects.ex
@@ -1896,6 +1896,47 @@ defmodule Lightning.Projects do
|> Repo.all()
end
+ @doc """
+ Lists a parent project's active sandboxes for the "Edit in sandbox" picker.
+
+ Returns only the direct children of `parent_id` that are not scheduled for
+ deletion, sorted by `updated_at` descending as a "last edited" proxy (there is
+ no dedicated last-editor field). Each sandbox preloads its `project_users` (and
+ their users) for collaborator display, and resolves the clone of the workflow
+ named `workflow_name` so the caller can offer a direct "join" target. The
+ resolved workflow id is returned as `:joinable_workflow_id`, or `nil` when the
+ sandbox has no workflow with that name.
+ """
+ @spec list_active_sandboxes_for_editing(Ecto.UUID.t(), String.t()) :: [
+ {Project.t(), Ecto.UUID.t() | nil}
+ ]
+ def list_active_sandboxes_for_editing(parent_id, workflow_name)
+ when is_binary(parent_id) and is_binary(workflow_name) do
+ sandboxes =
+ from(p in Project,
+ where: p.parent_id == ^parent_id and is_nil(p.scheduled_deletion),
+ order_by: [desc: p.updated_at],
+ preload: [project_users: :user]
+ )
+ |> Repo.all()
+
+ sandbox_ids = Enum.map(sandboxes, & &1.id)
+
+ joinable_workflow_ids =
+ from(w in Workflow,
+ where:
+ w.project_id in ^sandbox_ids and w.name == ^workflow_name and
+ is_nil(w.deleted_at),
+ select: {w.project_id, w.id}
+ )
+ |> Repo.all()
+ |> Map.new()
+
+ Enum.map(sandboxes, fn sandbox ->
+ {sandbox, Map.get(joinable_workflow_ids, sandbox.id)}
+ end)
+ end
+
@doc """
Checks if a sandbox with the given name exists under the parent project.
diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex
index cd0f93f38b..72e5d0184d 100644
--- a/lib/lightning/projects/sandboxes.ex
+++ b/lib/lightning/projects/sandboxes.ex
@@ -761,6 +761,10 @@ defmodule Lightning.Projects.Sandboxes do
enable_job_logs: parent_workflow.enable_job_logs,
positions: %{}
})
+ # Cloned triggers are inserted disabled, so the workflow must start as a
+ # draft to keep state and trigger-enabled status coherent. The caller can
+ # promote a specific clone to :live afterwards (e.g. "Edit in sandbox").
+ |> Ecto.Changeset.put_change(:state, :draft)
|> Repo.insert()
Map.put(mapping, parent_workflow.id, sandbox_workflow.id)
diff --git a/lib/lightning_web/channels/workflow_channel.ex b/lib/lightning_web/channels/workflow_channel.ex
index 5ff6449ce6..8b5b11a8c6 100644
--- a/lib/lightning_web/channels/workflow_channel.ex
+++ b/lib/lightning_web/channels/workflow_channel.ex
@@ -14,9 +14,12 @@ defmodule LightningWeb.WorkflowChannel do
alias Lightning.Collaboration.Utils
alias Lightning.Collaboration.WorkflowResolver
alias Lightning.Policies.Permissions
+ alias Lightning.Projects
+ alias Lightning.Projects.ProjectLimiter
alias Lightning.Repo
alias Lightning.VersionControl
alias Lightning.VersionControl.VersionControlUsageLimiter
+ alias Lightning.Workflows
alias Lightning.Workflows.Job
alias Lightning.Workflows.WorkflowUsageLimiter
alias Lightning.WorkOrders
@@ -383,6 +386,44 @@ defmodule LightningWeb.WorkflowChannel do
transition_lifecycle_state(socket, :draft)
end
+ @impl true
+ def handle_in("list_sandboxes", _params, socket) do
+ project = socket.assigns.project
+ workflow = socket.assigns.workflow
+
+ sandboxes =
+ project.id
+ |> Projects.list_active_sandboxes_for_editing(workflow.name)
+ |> Enum.map(&render_editable_sandbox/1)
+
+ {:reply, {:ok, %{sandboxes: sandboxes}}, socket}
+ end
+
+ @impl true
+ def handle_in("edit_in_sandbox", params, socket) do
+ parent = socket.assigns.project
+ workflow = socket.assigns.workflow
+ user = socket.assigns.current_user
+
+ name = sandbox_name(params, workflow, parent)
+
+ with :ok <- authorize_provision_sandbox(user, parent),
+ :ok <- limit_new_sandbox(parent),
+ {:ok, sandbox} <-
+ Projects.provision_sandbox(parent, user, %{
+ name: name,
+ env: "dev",
+ color: random_sandbox_color()
+ }),
+ {:ok, cloned_workflow} <-
+ promote_cloned_workflow(sandbox, workflow.name, user) do
+ {:reply, {:ok, %{project_id: sandbox.id, workflow_id: cloned_workflow.id}},
+ socket}
+ else
+ error -> workflow_error_reply(socket, error)
+ end
+ end
+
@impl true
def handle_in("save_and_sync", params, socket)
when not is_map_key(params, "commit_message") do
@@ -1149,6 +1190,93 @@ defmodule LightningWeb.WorkflowChannel do
end
end
+ defp authorize_provision_sandbox(user, parent) do
+ if Permissions.can?(:sandboxes, :provision_sandbox, user, parent) do
+ :ok
+ else
+ {:error,
+ %{
+ type: "unauthorized",
+ message: "You don't have permission to create a sandbox here"
+ }}
+ end
+ end
+
+ defp limit_new_sandbox(parent) do
+ case ProjectLimiter.limit_new_sandbox(parent.id) do
+ :ok -> :ok
+ {:error, _reason, message} -> {:error, message}
+ end
+ end
+
+ defp promote_cloned_workflow(sandbox, workflow_name, user) do
+ cloned =
+ from(w in Lightning.Workflows.Workflow,
+ where:
+ w.project_id == ^sandbox.id and w.name == ^workflow_name and
+ is_nil(w.deleted_at),
+ limit: 1
+ )
+ |> Repo.one()
+
+ case cloned do
+ %Lightning.Workflows.Workflow{} = workflow ->
+ Workflows.go_live(workflow, user)
+
+ nil ->
+ {:error, :internal_error}
+ end
+ end
+
+ defp sandbox_name(params, workflow, parent) do
+ raw =
+ case params do
+ %{"name" => name} when is_binary(name) and name != "" -> name
+ _ -> default_sandbox_name(workflow, parent)
+ end
+
+ Lightning.Helpers.url_safe_name(raw)
+ end
+
+ defp default_sandbox_name(workflow, parent) do
+ base = workflow.name || parent.name || "sandbox"
+ "#{base}-sandbox"
+ end
+
+ defp random_sandbox_color do
+ LightningWeb.SandboxLive.Components.color_palette_hex_colors()
+ |> Enum.random()
+ end
+
+ defp render_editable_sandbox({sandbox, joinable_workflow_id}) do
+ %{
+ id: sandbox.id,
+ name: sandbox.name,
+ color: sandbox.color,
+ updated_at: sandbox.updated_at,
+ collaborators: Enum.map(sandbox.project_users, &render_collaborator/1),
+ workflow_id: joinable_workflow_id
+ }
+ end
+
+ defp render_collaborator(%{user: user}) do
+ %{
+ id: user.id,
+ name: collaborator_name(user),
+ email: user.email
+ }
+ end
+
+ defp collaborator_name(user) do
+ [user.first_name, user.last_name]
+ |> Enum.reject(&(is_nil(&1) or &1 == ""))
+ |> Enum.join(" ")
+ |> case do
+ "" -> user.email
+ name -> name
+ end
+ end
+
defp authorize_publish_template(socket) do
user = socket.assigns.current_user
project = socket.assigns.project
diff --git a/test/lightning/sandboxes_test.exs b/test/lightning/sandboxes_test.exs
index bdf9b24043..e55a125e49 100644
--- a/test/lightning/sandboxes_test.exs
+++ b/test/lightning/sandboxes_test.exs
@@ -389,6 +389,10 @@ defmodule Lightning.Projects.SandboxesTest do
assert s_triggers != []
assert Enum.all?(s_triggers, &match?(false, &1.enabled))
+ # Cloned workflows must start as drafts so their :state stays coherent with
+ # their disabled triggers.
+ assert Enum.all?(s_wfs, &(&1.state == :draft))
+
s_edges =
from(e in Edge,
join: w in assoc(e, :workflow),
diff --git a/test/lightning_web/channels/workflow_channel_test.exs b/test/lightning_web/channels/workflow_channel_test.exs
index afb0759483..517b5c0e2a 100644
--- a/test/lightning_web/channels/workflow_channel_test.exs
+++ b/test/lightning_web/channels/workflow_channel_test.exs
@@ -78,6 +78,191 @@ defmodule LightningWeb.WorkflowChannelTest do
end
end
+ describe "list_sandboxes" do
+ test "returns active sandboxes sorted by last edited with joinable workflow and collaborators",
+ %{socket: socket, project: project, workflow: workflow} do
+ collaborator =
+ insert(:user, first_name: "Ada", last_name: "Lovelace")
+
+ # Older sandbox with a matching workflow clone (joinable).
+ older =
+ insert(:project,
+ parent: project,
+ color: "#111111",
+ project_users: [%{user: collaborator, role: :editor}]
+ )
+
+ joinable =
+ insert(:workflow, project: older, name: workflow.name)
+
+ # Newer sandbox without a matching workflow name (not joinable).
+ newer = insert(:project, parent: project, color: "#222222")
+ insert(:workflow, project: newer, name: "something-else")
+
+ # Bump newer's updated_at so it sorts first.
+ Lightning.Repo.update!(
+ Ecto.Changeset.change(newer, updated_at: ~U[2030-01-01 00:00:00Z])
+ )
+
+ Lightning.Repo.update!(
+ Ecto.Changeset.change(older, updated_at: ~U[2020-01-01 00:00:00Z])
+ )
+
+ ref = push(socket, "list_sandboxes", %{})
+ assert_reply ref, :ok, %{sandboxes: sandboxes}
+
+ assert [first, second] = sandboxes
+ assert first.id == newer.id
+ assert first.workflow_id == nil
+ assert second.id == older.id
+ assert second.workflow_id == joinable.id
+
+ assert [%{name: "Ada Lovelace", email: collaborator_email}] =
+ second.collaborators
+
+ assert collaborator_email == collaborator.email
+ end
+
+ test "excludes sandboxes scheduled for deletion", %{
+ socket: socket,
+ project: project
+ } do
+ active = insert(:project, parent: project)
+
+ scheduled =
+ insert(:project,
+ parent: project,
+ scheduled_deletion: ~U[2030-01-01 00:00:00Z]
+ )
+
+ ref = push(socket, "list_sandboxes", %{})
+ assert_reply ref, :ok, %{sandboxes: sandboxes}
+
+ ids = Enum.map(sandboxes, & &1.id)
+ assert active.id in ids
+ refute scheduled.id in ids
+ end
+ end
+
+ describe "edit_in_sandbox" do
+ setup %{project: project, workflow: workflow} do
+ Mox.stub_with(
+ Lightning.Extensions.MockProjectHook,
+ Lightning.Extensions.ProjectHook
+ )
+
+ # Give the workflow a real trigger so the clone has something to enable.
+ trigger =
+ insert(:trigger, workflow: workflow, type: :webhook, enabled: true)
+
+ job = insert(:job, workflow: workflow)
+
+ insert(:edge,
+ workflow: workflow,
+ source_trigger: trigger,
+ target_job: job,
+ condition_type: :always
+ )
+
+ # A second, unrelated workflow in the same project: its clone must stay
+ # a draft with disabled triggers.
+ other_workflow = insert(:workflow, project: project, name: "other-wf")
+ insert(:trigger, workflow: other_workflow, type: :webhook, enabled: true)
+
+ %{trigger: trigger, other_workflow: other_workflow}
+ end
+
+ test "provisions a sandbox, promotes the edited workflow, leaves others as drafts",
+ %{
+ socket: socket,
+ project: project,
+ workflow: workflow,
+ other_workflow: other_workflow
+ } do
+ ref = push(socket, "edit_in_sandbox", %{})
+ assert_reply ref, :ok, %{project_id: sandbox_id, workflow_id: cloned_id}
+
+ sandbox = Lightning.Projects.get_project!(sandbox_id)
+ assert sandbox.parent_id == project.id
+
+ # Edited clone is live with an enabled trigger.
+ cloned =
+ Lightning.Workflows.get_workflow!(cloned_id, include: [:triggers])
+
+ assert cloned.name == workflow.name
+ assert cloned.state == :live
+ assert Enum.all?(cloned.triggers, & &1.enabled)
+
+ # The other clone stays a draft with disabled triggers.
+ other_clone =
+ Lightning.Workflows.Workflow
+ |> Lightning.Repo.get_by(
+ project_id: sandbox_id,
+ name: other_workflow.name
+ )
+ |> Lightning.Repo.preload(:triggers)
+
+ assert other_clone.state == :draft
+ refute Enum.any?(other_clone.triggers, & &1.enabled)
+
+ # Parent workflow is untouched.
+ parent_workflow =
+ Lightning.Workflows.get_workflow!(workflow.id, include: [:triggers])
+
+ assert parent_workflow.state == workflow.state
+ end
+
+ test "uses a provided name", %{socket: socket} do
+ ref = push(socket, "edit_in_sandbox", %{"name" => "My Custom Name"})
+ assert_reply ref, :ok, %{project_id: sandbox_id}
+
+ sandbox = Lightning.Projects.get_project!(sandbox_id)
+ assert sandbox.name == "my-custom-name"
+ end
+
+ test "respects the new-sandbox usage limit", %{socket: socket} do
+ message = %Lightning.Extensions.Message{text: "Sandbox limit reached"}
+
+ Mox.stub(
+ Lightning.Extensions.MockUsageLimiter,
+ :limit_action,
+ fn
+ %{type: :new_sandbox}, _ctx -> {:error, :too_many_sandboxes, message}
+ _action, _ctx -> :ok
+ end
+ )
+
+ ref = push(socket, "edit_in_sandbox", %{})
+
+ assert_reply ref, :error, %{
+ type: "limit_error",
+ errors: %{base: ["Sandbox limit reached"]}
+ }
+ end
+
+ test "rejects users without provision permission", %{
+ project: project,
+ workflow: workflow
+ } do
+ viewer = insert(:user)
+ insert(:project_user, project: project, user: viewer, role: :viewer)
+
+ {:ok, _, viewer_socket} =
+ LightningWeb.UserSocket
+ |> socket("user_#{viewer.id}", %{current_user: viewer})
+ |> subscribe_and_join(
+ LightningWeb.WorkflowChannel,
+ "workflow:collaborate:#{workflow.id}",
+ %{"project_id" => project.id, "action" => "edit"}
+ )
+
+ on_exit(fn -> ensure_doc_supervisor_stopped(workflow.id) end)
+
+ ref = push(viewer_socket, "edit_in_sandbox", %{})
+ assert_reply ref, :error, %{type: "unauthorized"}
+ end
+ end
+
describe "join authorization" do
test "rejects unauthorized users", %{workflow: workflow, project: project} do
unauthorized_user = insert(:user)