Skip to content

Commit e8e6577

Browse files
Feat/prototype agents; feature flagged (#432)
1 parent a625d5d commit e8e6577

12 files changed

Lines changed: 354 additions & 75 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Metadata } from 'next'
2+
import { notFound, redirect } from 'next/navigation'
3+
import { AUTH_URLS } from '@/configs/urls'
4+
import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server'
5+
import { getAuthContext } from '@/core/server/auth'
6+
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'
7+
import { AgentsList } from '@/features/dashboard/agents/agents-list'
8+
import { AGENT_TEMPLATES } from '@/features/dashboard/agents/config'
9+
import { Page } from '@/features/dashboard/layouts/page'
10+
11+
export const metadata: Metadata = {
12+
title: 'Agents - E2B',
13+
}
14+
15+
type AgentsPageProps = {
16+
params: Promise<{
17+
teamSlug: string
18+
}>
19+
}
20+
21+
export default async function AgentsPage({ params }: AgentsPageProps) {
22+
const [{ teamSlug }, authContext] = await Promise.all([
23+
params,
24+
getAuthContext(),
25+
])
26+
27+
if (!authContext) {
28+
redirect(AUTH_URLS.SIGN_IN)
29+
}
30+
31+
const teamId = await getTeamIdFromSlug(teamSlug, authContext.accessToken)
32+
33+
if (!teamId.ok || !teamId.data) {
34+
notFound()
35+
}
36+
37+
const agentsEnabled = await featureFlags.isEnabled('agentsEnabled', {
38+
user: {
39+
id: authContext.user.id,
40+
email: authContext.user.email ?? undefined,
41+
},
42+
team: {
43+
id: teamId.data,
44+
slug: teamSlug,
45+
},
46+
})
47+
48+
if (!agentsEnabled) {
49+
notFound()
50+
}
51+
52+
return (
53+
<Page className="flex flex-col gap-4">
54+
<div className="flex flex-col gap-1">
55+
<h2 className="prose-title text-fg">Agents</h2>
56+
<p className="prose-body text-fg-tertiary max-w-2xl">
57+
Start a coding-agent sandbox and open it in the dashboard terminal.
58+
</p>
59+
</div>
60+
61+
<AgentsList agents={AGENT_TEMPLATES} />
62+
</Page>
63+
)
64+
}

src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ interface SandboxTerminalPageProps {
99
params: Promise<{
1010
teamSlug: string
1111
}>
12+
searchParams: Promise<{
13+
command?: string
14+
}>
1215
}
1316

1417
export default async function SandboxTerminalPage({
1518
params,
19+
searchParams,
1620
}: SandboxTerminalPageProps) {
17-
const [{ teamSlug }, authContext] = await Promise.all([
21+
const [{ teamSlug }, { command = '' }, authContext] = await Promise.all([
1822
params,
23+
searchParams,
1924
getAuthContext(),
2025
])
2126

@@ -33,6 +38,7 @@ export default async function SandboxTerminalPage({
3338

3439
return (
3540
<SandboxTerminalView
41+
command={command}
3642
sandboxManagementAuth={createSandboxManagementAuth(
3743
authContext,
3844
teamId.data

src/app/dashboard/terminal/page.tsx

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ export default async function TerminalPage({
3434
searchParams,
3535
}: TerminalPageProps) {
3636
const { command = '', sandboxId, template } = await searchParams
37-
const terminalTemplate = normalizeTerminalTemplate(template)
3837
const terminalSandboxId = normalizeTerminalSandboxId(sandboxId)
38+
const requestedTemplate = normalizeTerminalTemplate(template)
3939

40-
if (!terminalTemplate) {
40+
if (!terminalSandboxId && !requestedTemplate) {
4141
return <TerminalUnavailable message="The terminal template is invalid." />
4242
}
4343

@@ -51,7 +51,7 @@ export default async function TerminalPage({
5151
return (
5252
<TerminalSignIn
5353
sandboxId={terminalSandboxId}
54-
template={terminalTemplate}
54+
template={requestedTemplate ?? 'base'}
5555
/>
5656
)
5757
}
@@ -69,14 +69,18 @@ export default async function TerminalPage({
6969
authContext.user.id,
7070
authContext.accessToken
7171
)
72-
const team = terminalSandboxId
73-
? await resolveTerminalSandboxTeam({
72+
const terminalSandbox = terminalSandboxId
73+
? await resolveTerminalSandbox({
7474
accessToken: authContext.accessToken,
7575
preferredTeamId: resolvedTeam?.id,
7676
sandboxId: terminalSandboxId,
7777
teams: teamsResult.data,
7878
})
79+
: null
80+
const team = terminalSandbox
81+
? terminalSandbox.team
7982
: teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id)
83+
const terminalTemplate = terminalSandbox?.template ?? requestedTemplate
8084

8185
if (!team) {
8286
return (
@@ -95,7 +99,7 @@ export default async function TerminalPage({
9599
: await isTerminalTemplateAvailable({
96100
accessToken: authContext.accessToken,
97101
teamId: team.id,
98-
template: terminalTemplate,
102+
template: terminalTemplate ?? 'base',
99103
})
100104

101105
if (!templateAvailable.ok) {
@@ -119,7 +123,7 @@ export default async function TerminalPage({
119123
launchTarget={{
120124
command,
121125
sandboxId: terminalSandboxId,
122-
template: terminalTemplate,
126+
template: terminalTemplate ?? 'base',
123127
}}
124128
sandboxManagementAuth={createSandboxManagementAuth(
125129
authContext,
@@ -139,7 +143,7 @@ function normalizeTerminalSandboxId(sandboxId?: string) {
139143
return parsedSandboxId.success ? parsedSandboxId.data : null
140144
}
141145

142-
async function resolveTerminalSandboxTeam({
146+
async function resolveTerminalSandbox({
143147
accessToken,
144148
preferredTeamId,
145149
sandboxId,
@@ -152,34 +156,54 @@ async function resolveTerminalSandboxTeam({
152156
}) {
153157
if (preferredTeamId) {
154158
const preferredTeam = teams.find((team) => team.id === preferredTeamId)
155-
if (
156-
preferredTeam &&
157-
(await hasSandboxInTeam({
159+
if (preferredTeam) {
160+
const preferredSandbox = await getSandboxInTeam({
158161
accessToken,
159162
sandboxId,
160163
teamId: preferredTeam.id,
161-
}))
162-
) {
163-
return preferredTeam
164+
})
165+
166+
if (preferredSandbox) {
167+
return {
168+
team: preferredTeam,
169+
template: getSandboxTemplate(preferredSandbox),
170+
}
171+
}
164172
}
165173
}
166174

167175
const candidateTeams = teams.filter((team) => team.id !== preferredTeamId)
168176
const teamMatches = await Promise.all(
169177
candidateTeams.map(async (team) => ({
170-
team,
171-
ownsSandbox: await hasSandboxInTeam({
178+
sandbox: await getSandboxInTeam({
172179
accessToken,
173180
sandboxId,
174181
teamId: team.id,
175182
}),
183+
team,
176184
}))
177185
)
178186

179-
return teamMatches.find((match) => match.ownsSandbox)?.team ?? null
187+
const match = teamMatches.find(({ sandbox }) => sandbox)
188+
189+
return match?.sandbox
190+
? {
191+
team: match.team,
192+
template: getSandboxTemplate(match.sandbox),
193+
}
194+
: null
195+
}
196+
197+
type TerminalSandbox = {
198+
alias?: string
199+
templateID: string
200+
}
201+
202+
function getSandboxTemplate(sandbox: TerminalSandbox) {
203+
return sandbox.alias ?? sandbox.templateID
180204
}
181205

182-
async function hasSandboxInTeam({
206+
async function getSandboxInTeam({
183207
accessToken,
184208
sandboxId,
185209
teamId,
@@ -201,9 +225,9 @@ async function hasSandboxInTeam({
201225
cache: 'no-store',
202226
})
203227

204-
return result.response.ok && Boolean(result.data)
228+
return result.response.ok && result.data ? result.data : null
205229
} catch {
206-
return false
230+
return null
207231
}
208232
}
209233

src/app/sbx/new/route.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,24 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
55
import { getAuthContext } from '@/core/server/auth'
66
import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
77
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
8+
import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template'
89

910
export const GET = async (req: NextRequest) => {
1011
try {
12+
const requestUrl = new URL(req.url)
13+
const template = normalizeTerminalTemplate(
14+
requestUrl.searchParams.get('template') ?? undefined
15+
)
16+
17+
if (!template) {
18+
return NextResponse.redirect(new URL(req.url).origin)
19+
}
20+
1121
const authContext = await getAuthContext()
1222

1323
if (!authContext) {
1424
const params = new URLSearchParams({
15-
returnTo: new URL(req.url).pathname,
25+
returnTo: `${requestUrl.pathname}${requestUrl.search}`,
1626
})
1727

1828
return NextResponse.redirect(
@@ -29,20 +39,29 @@ export const GET = async (req: NextRequest) => {
2939
return NextResponse.redirect(new URL(req.url).origin)
3040
}
3141

32-
const sbx = await Sandbox.create('base', {
42+
const sbx = await Sandbox.create(template, {
3343
apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL,
3444
domain: process.env.NEXT_PUBLIC_E2B_DOMAIN,
3545
apiHeaders: {
3646
...authHeaders(authContext.accessToken, team.id),
3747
},
3848
})
3949

50+
const terminalParams = new URLSearchParams({ template })
51+
const command = requestUrl.searchParams.get('command')?.trim()
52+
53+
if (command) {
54+
terminalParams.set('command', command)
55+
}
56+
4057
const terminalUrl = PROTECTED_URLS.SANDBOX_TERMINAL(
4158
team.slug,
4259
sbx.sandboxId
4360
)
4461

45-
return NextResponse.redirect(new URL(terminalUrl, req.url))
62+
return NextResponse.redirect(
63+
new URL(`${terminalUrl}?${terminalParams.toString()}`, req.url)
64+
)
4665
} catch (error) {
4766
l.warn(
4867
{

src/configs/layout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record<
118118
title: 'Members',
119119
type: 'default',
120120
}),
121+
'/dashboard/*/agents': () => ({
122+
title: 'Agents',
123+
type: 'default',
124+
}),
121125

122126
// billing
123127
'/dashboard/*/usage': () => ({

src/core/modules/feature-flags/definitions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { FeatureFlagDefinition } from '@/core/modules/feature-flags/types'
22

33
export const FEATURE_FLAGS = {
4+
agentsEnabled: {
5+
kind: 'boolean',
6+
key: 'agents_enabled',
7+
defaultValue: false,
8+
description: 'Enables the dashboard agents launcher.',
9+
exposure: 'server',
10+
},
411
isAdmin: {
512
kind: 'boolean',
613
key: 'is_admin',
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import type { ComponentType } from 'react'
5+
import { SiClaude, SiOpenai } from 'react-icons/si'
6+
import { cn } from '@/lib/utils'
7+
import { Button } from '@/ui/primitives/button'
8+
import { ExternalLinkIcon, UnpackIcon } from '@/ui/primitives/icons'
9+
import type { AgentTemplateConfig } from './config'
10+
11+
const AGENT_ICONS = {
12+
claude: SiClaude,
13+
open: UnpackIcon,
14+
openai: SiOpenai,
15+
} satisfies Record<
16+
AgentTemplateConfig['icon'],
17+
ComponentType<{ className?: string }>
18+
>
19+
20+
function getLaunchHref(agent: AgentTemplateConfig) {
21+
const params = new URLSearchParams({
22+
command: agent.command,
23+
template: agent.template,
24+
})
25+
26+
return `/sbx/new?${params.toString()}`
27+
}
28+
29+
export function AgentsList({
30+
agents,
31+
className,
32+
}: {
33+
agents: AgentTemplateConfig[]
34+
className?: string
35+
}) {
36+
return (
37+
<div className={cn('grid gap-3 sm:grid-cols-2 xl:grid-cols-3', className)}>
38+
{agents.map((agent) => (
39+
<AgentCard agent={agent} key={agent.id} />
40+
))}
41+
</div>
42+
)
43+
}
44+
45+
function AgentCard({ agent }: { agent: AgentTemplateConfig }) {
46+
const AgentIcon = AGENT_ICONS[agent.icon]
47+
48+
return (
49+
<section className="border-stroke bg-bg-1 flex min-h-44 flex-col rounded-lg border p-4">
50+
<div className="flex min-w-0 items-start gap-3">
51+
<div className="border-stroke bg-bg flex size-9 shrink-0 items-center justify-center rounded-md border">
52+
<AgentIcon className="size-4" />
53+
</div>
54+
<div className="min-w-0">
55+
<h3 className="prose-body-highlight text-fg truncate">
56+
{agent.name}
57+
</h3>
58+
<p className="prose-body text-fg-tertiary mt-1 line-clamp-2">
59+
{agent.description}
60+
</p>
61+
</div>
62+
</div>
63+
64+
<div className="prose-label text-fg-tertiary mt-auto pt-4 uppercase">
65+
{agent.template}
66+
</div>
67+
68+
<Button asChild className="mt-3 w-full" variant="primary">
69+
<Link href={getLaunchHref(agent)} prefetch={false}>
70+
Start
71+
<ExternalLinkIcon />
72+
</Link>
73+
</Button>
74+
</section>
75+
)
76+
}

0 commit comments

Comments
 (0)