Skip to content

Commit 16fd9b5

Browse files
committed
feat(sandboxes): create a sandbox from the picker, and fix three UI papercuts
The Function block's sandbox field now pins a "Create Sandbox" row above its options, matching the "Create Skill" / "Create Tool" rows it sits beside, so authoring a package list no longer means leaving the workflow for Settings. The row is declared by the field (`createAction`) rather than hardcoded by id; block configs are read by the serializer and executor, so the name maps to a modal in the picker rather than carrying a component. Two things the modal has to get right. It seeds the new sandbox's language from the sibling the list is scoped by, or a sandbox created off a JavaScript block would land in the Python list and vanish. And the created option is held locally until a real fetch carries it, or the field would sit on a raw uuid until hydration answered. Also: - The Sandboxes icon was the Logs block's icon (`blocks/blocks/logs.ts`), in both the settings nav and the list rows. It is the Function block's now. - "Default image (no extra packages)" claimed something untrue: E2B and Daytona base images both ship with packages installed. - A new sandbox opened in Python while the Function block defaults to JavaScript. The test pins the two together rather than the literal. Draft shape and helpers moved out of the editor component into `utils.ts` — three consumers now, and it makes the defaults testable without a DOM.
1 parent 56d0ee7 commit 16fd9b5

10 files changed

Lines changed: 389 additions & 58 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import {
5+
ChipDropdown,
6+
ChipModal,
7+
ChipModalBody,
8+
ChipModalError,
9+
ChipModalField,
10+
ChipModalFooter,
11+
ChipModalHeader,
12+
} from '@sim/emcn'
13+
import { getErrorMessage } from '@sim/utils/errors'
14+
import { useParams } from 'next/navigation'
15+
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
16+
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
17+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
18+
import {
19+
DEPENDENCY_PLACEHOLDERS,
20+
emptyDraft,
21+
extractIssues,
22+
LANGUAGE_OPTIONS,
23+
type SandboxDraft,
24+
type SandboxLanguage,
25+
toSubmittedLines,
26+
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
27+
import { type Sandbox, useCreateSandbox, useSandboxes } from '@/hooks/queries/sandboxes'
28+
29+
const NOT_ENTITLED_MESSAGE = 'Sandboxes require an active Max plan.'
30+
const NOT_ADMIN_MESSAGE = 'Only workspace admins can create sandboxes.'
31+
32+
interface SandboxCreateModalProps {
33+
open: boolean
34+
onOpenChange: (open: boolean) => void
35+
/**
36+
* Seeds the language of the new sandbox. Pickers scope their list to one
37+
* language, so without this a sandbox created from a JavaScript block could
38+
* land in the Python list and never appear.
39+
*/
40+
defaultLanguage?: SandboxLanguage
41+
/** Receives the created sandbox so the caller can select it. */
42+
onCreated?: (sandbox: Sandbox) => void
43+
}
44+
45+
/**
46+
* Creates a sandbox from wherever one is being picked, so authoring a package
47+
* list never means leaving the workflow for Settings.
48+
*
49+
* Editing stays in Settings — this is deliberately create-only, matching the
50+
* "Create Skill" / "Create Tool" rows it sits beside.
51+
*/
52+
export function SandboxCreateModal({
53+
open,
54+
onOpenChange,
55+
defaultLanguage,
56+
onCreated,
57+
}: SandboxCreateModalProps) {
58+
const params = useParams()
59+
const workspaceId = params.workspaceId as string
60+
61+
// Keyed off `open` so the list is not fetched by every mounted picker, only by
62+
// one the user actually opened. It shares the picker's cache entry either way.
63+
const { data } = useSandboxes(open ? workspaceId : undefined)
64+
const permissions = useUserPermissionsContext()
65+
const canAdmin = canMutateWorkspaceSettingsSection('sandboxes', permissions)
66+
// Assume entitled until the list answers — a flash of the upgrade copy on a
67+
// workspace that has it would be worse than a late, accurate one.
68+
const entitled = data?.entitled ?? true
69+
70+
const createSandbox = useCreateSandbox()
71+
72+
const [draft, setDraft] = useState<SandboxDraft>(emptyDraft)
73+
const [issues, setIssues] = useState<SandboxDependencyIssue[]>([])
74+
const [error, setError] = useState<string | null>(null)
75+
76+
// The modal stays mounted for its exit animation, so each opening has to clear
77+
// what the last one left behind.
78+
const [wasOpen, setWasOpen] = useState(open)
79+
if (wasOpen !== open) {
80+
setWasOpen(open)
81+
if (open) {
82+
setDraft({ ...emptyDraft(), ...(defaultLanguage ? { language: defaultLanguage } : {}) })
83+
setIssues([])
84+
setError(null)
85+
}
86+
}
87+
88+
const blockedReason = !canAdmin ? NOT_ADMIN_MESSAGE : !entitled ? NOT_ENTITLED_MESSAGE : null
89+
const saving = createSandbox.isPending
90+
const disabled = saving || blockedReason !== null
91+
92+
const handleCreate = async () => {
93+
setIssues([])
94+
setError(null)
95+
try {
96+
const { sandbox } = await createSandbox.mutateAsync({
97+
workspaceId,
98+
name: draft.name.trim(),
99+
language: draft.language,
100+
dependencies: toSubmittedLines(draft.dependencies),
101+
})
102+
onCreated?.(sandbox)
103+
onOpenChange(false)
104+
} catch (caught) {
105+
const lineIssues = extractIssues(caught)
106+
if (lineIssues.length > 0) {
107+
setIssues(lineIssues)
108+
return
109+
}
110+
setError(getErrorMessage(caught, 'Failed to create sandbox'))
111+
}
112+
}
113+
114+
return (
115+
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create sandbox'>
116+
<ChipModalHeader onClose={() => onOpenChange(false)}>Create sandbox</ChipModalHeader>
117+
118+
<ChipModalBody>
119+
<ChipModalField
120+
type='input'
121+
title='Name'
122+
value={draft.name}
123+
onChange={(name) => setDraft((prev) => ({ ...prev, name }))}
124+
placeholder='bigquery-etl'
125+
maxLength={64}
126+
autoComplete='off'
127+
required
128+
disabled={disabled}
129+
/>
130+
131+
<ChipModalField type='custom' title='Language'>
132+
<ChipDropdown
133+
value={draft.language}
134+
onChange={(language) =>
135+
setDraft((prev) => ({ ...prev, language: language as SandboxLanguage }))
136+
}
137+
options={LANGUAGE_OPTIONS.map((option) => ({
138+
label: option.label,
139+
value: option.value,
140+
}))}
141+
disabled={disabled}
142+
aria-label='Language'
143+
/>
144+
</ChipModalField>
145+
146+
<ChipModalField
147+
type='textarea'
148+
title='Dependencies'
149+
value={draft.dependencies}
150+
onChange={(dependencies) => setDraft((prev) => ({ ...prev, dependencies }))}
151+
placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]}
152+
rows={8}
153+
disabled={disabled}
154+
hint='One per line. Version pins are optional.'
155+
error={
156+
issues.length > 0 ? (
157+
<>
158+
{issues.map((issue) => (
159+
<span key={issue.line} className='block'>
160+
Line {issue.line}: {issue.reason}
161+
</span>
162+
))}
163+
</>
164+
) : undefined
165+
}
166+
/>
167+
168+
<ChipModalError>{blockedReason ?? error}</ChipModalError>
169+
</ChipModalBody>
170+
171+
<ChipModalFooter
172+
onCancel={() => onOpenChange(false)}
173+
primaryAction={{
174+
label: saving ? 'Creating...' : 'Create',
175+
onClick: () => void handleCreate(),
176+
disabled: disabled || draft.name.trim().length === 0,
177+
}}
178+
/>
179+
</ChipModal>
180+
)
181+
}

apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,15 @@
33
import { useMemo, useState } from 'react'
44
import { Chip, ChipDropdown, ChipInput, ChipTextarea, cn } from '@sim/emcn'
55
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
6+
import {
7+
DEPENDENCY_PLACEHOLDERS,
8+
LANGUAGE_OPTIONS,
9+
type SandboxDraft,
10+
type SandboxLanguage,
11+
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
612
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
713
import type { Sandbox } from '@/hooks/queries/sandboxes'
814

9-
const LANGUAGE_OPTIONS = [
10-
{ label: 'JavaScript', value: 'javascript' },
11-
{ label: 'Python', value: 'python' },
12-
] as const
13-
14-
/**
15-
* Placeholders double as the format documentation, so they swap with the
16-
* language rather than describing one syntax for both.
17-
*/
18-
const PLACEHOLDERS: Record<SandboxLanguage, string> = {
19-
python: 'google-cloud-bigquery==3.25.0\npyairtable>=3.0\npandas',
20-
javascript: 'axios@^1.7.0\n@aws-sdk/client-s3\nzod',
21-
}
22-
23-
type SandboxLanguage = Sandbox['language']
24-
25-
export interface SandboxDraft {
26-
name: string
27-
language: SandboxLanguage
28-
/** Raw textarea contents — one dependency per line, comments allowed. */
29-
dependencies: string
30-
}
31-
32-
export function draftFromSandbox(sandbox: Sandbox): SandboxDraft {
33-
return {
34-
name: sandbox.name,
35-
language: sandbox.language,
36-
dependencies: sandbox.dependencies.join('\n'),
37-
}
38-
}
39-
40-
export function emptyDraft(): SandboxDraft {
41-
return { name: '', language: 'python', dependencies: '' }
42-
}
43-
4415
interface SandboxEditorProps {
4516
draft: SandboxDraft
4617
onChange: (draft: SandboxDraft) => void
@@ -101,7 +72,7 @@ export function SandboxEditor({
10172
<ChipTextarea
10273
value={draft.dependencies}
10374
onChange={(event) => onChange({ ...draft, dependencies: event.target.value })}
104-
placeholder={PLACEHOLDERS[draft.language]}
75+
placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]}
10576
rows={8}
10677
disabled={disabled}
10778
error={issues.length > 0}

apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,31 @@
22

33
import { useCallback, useMemo, useState } from 'react'
44
import { Chip, toast } from '@sim/emcn'
5-
import { ArrowLeft, ArrowRight, Library, Plus } from '@sim/emcn/icons'
5+
import { ArrowLeft, ArrowRight, Plus } from '@sim/emcn/icons'
66
import { getErrorMessage } from '@sim/utils/errors'
77
import { useParams } from 'next/navigation'
88
import { useQueryState } from 'nuqs'
9+
import { CodeIcon } from '@/components/icons'
910
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
1011
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
1112
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
1213
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1314
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
1415
import {
15-
draftFromSandbox,
16-
emptyDraft,
17-
type SandboxDraft,
1816
SandboxEditor,
1917
SandboxStatus,
2018
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor'
2119
import {
2220
sandboxIdParam,
2321
sandboxIdUrlKeys,
2422
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/search-params'
23+
import {
24+
draftFromSandbox,
25+
emptyDraft,
26+
extractIssues,
27+
type SandboxDraft,
28+
toSubmittedLines,
29+
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
2530
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
2631
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
2732
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -38,16 +43,6 @@ import {
3843
} from '@/hooks/queries/sandboxes'
3944
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4045

41-
/** Splits the textarea into one entry per row so a rejection keeps its line number. */
42-
function toSubmittedLines(dependencies: string): string[] {
43-
return dependencies.split('\n')
44-
}
45-
46-
function extractIssues(error: unknown): SandboxDependencyIssue[] {
47-
const issues = (error as { body?: { issues?: SandboxDependencyIssue[] } })?.body?.issues
48-
return Array.isArray(issues) ? issues : []
49-
}
50-
5146
export function Sandboxes() {
5247
const params = useParams()
5348
const workspaceId = params.workspaceId as string
@@ -286,7 +281,7 @@ export function Sandboxes() {
286281
{filtered.map((sandbox) => (
287282
<SettingsResourceRow
288283
key={sandbox.id}
289-
icon={<Library />}
284+
icon={<CodeIcon />}
290285
title={
291286
<button
292287
type='button'
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
emptyDraft,
7+
extractIssues,
8+
LANGUAGE_OPTIONS,
9+
toSubmittedLines,
10+
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
11+
import { FunctionBlock } from '@/blocks/blocks/function'
12+
13+
const languageField = FunctionBlock.subBlocks.find((subBlock) => subBlock.id === 'language')
14+
const sandboxField = FunctionBlock.subBlocks.find((subBlock) => subBlock.id === 'sandboxId')
15+
16+
describe('sandbox draft defaults', () => {
17+
it('starts a new sandbox in the language the Function block itself defaults to', () => {
18+
const blockDefault = typeof languageField?.value === 'function' ? languageField.value() : null
19+
expect(blockDefault).toBe('javascript')
20+
expect(emptyDraft().language).toBe(blockDefault)
21+
})
22+
23+
it('offers languages in the Function block dropdown order', () => {
24+
expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(
25+
languageField?.options?.map((option) => (typeof option === 'string' ? option : option.id))
26+
)
27+
})
28+
29+
it('starts empty so nothing is submitted by accident', () => {
30+
expect(emptyDraft()).toEqual({ name: '', language: 'javascript', dependencies: '' })
31+
})
32+
})
33+
34+
describe('sandbox picker create action', () => {
35+
it('declares the inline create row the picker renders', () => {
36+
expect(sandboxField?.createAction).toBe('sandbox')
37+
})
38+
})
39+
40+
describe('toSubmittedLines', () => {
41+
it('keeps blank rows so a rejection can address the line the user typed on', () => {
42+
expect(toSubmittedLines('axios\n\nzod')).toEqual(['axios', '', 'zod'])
43+
})
44+
})
45+
46+
describe('extractIssues', () => {
47+
it('reads the per-line rejections off a failed save', () => {
48+
const error = { body: { issues: [{ line: 2, reason: 'not a package name' }] } }
49+
expect(extractIssues(error)).toEqual([{ line: 2, reason: 'not a package name' }])
50+
})
51+
52+
it('returns nothing for an error that carries no issues', () => {
53+
expect(extractIssues(new Error('network'))).toEqual([])
54+
expect(extractIssues({ body: { issues: 'nope' } })).toEqual([])
55+
expect(extractIssues(undefined)).toEqual([])
56+
})
57+
})

0 commit comments

Comments
 (0)