Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/core/modules/support/repository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface SupportRepository {
customerEmail: string
accountOwnerEmail: string
customerTier: string
titleOverride?: string
}): Promise<RepoResult<{ threadId: string }>>
}

Expand Down Expand Up @@ -170,6 +171,7 @@ export function createSupportRepository(
customerEmail,
accountOwnerEmail,
customerTier,
titleOverride,
} = input

const client = deps.createPlainClient()
Expand Down Expand Up @@ -224,7 +226,7 @@ export function createSupportRepository(
}
}

const title = `Support Request [${teamName}]`
const title = `${titleOverride ?? 'Support Request'} [${teamName}]`
const threadText = formatThreadText({
description,
teamId,
Expand Down
146 changes: 146 additions & 0 deletions src/core/modules/support/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
export const SUPPORT_TEMPLATES = [
{
id: 'sandbox_issue',
label: 'Issue with a sandbox',
title: 'Sandbox issue',
prefill: `Sandbox ID:
What you were trying to do:
What went wrong (error message, logs, or unexpected behavior):
Approximate time it happened (UTC if possible):
Steps to reproduce, if known: `,
},
{
id: 'snapshot_pause_resume',
label: 'Snapshot, pause, or resume failed',
title: 'Snapshot / pause / resume issue',
prefill: `Sandbox ID:
Snapshot or template ID (if applicable):
Which action failed (snapshot creation, pause, resume):
Error message returned:
Approximate time it happened (UTC if possible): `,
},
{
id: 'slow_performance',
label: 'Performance is slow',
title: 'Performance issue',
prefill: `Sandbox ID(s):
What is slow (sandbox start, command execution, network, file system, etc.):
How long it currently takes:
How long you expect it to take:
Approximate time of a recent slow run (UTC if possible): `,
},
{
id: 'cancel_subscription',
label: 'Cancel my subscription',
title: 'Subscription cancellation',
prefill: `Team ID:
Email on the payment method:
Reason for canceling (optional, helps us improve): `,
},
{
id: 'refund_or_credit',
label: 'Refund or credit request',
title: 'Refund / credit request',
prefill: `Team ID:
Invoice number or charge date:
Amount:
Reason for the request: `,
},
{
id: 'delete_account',
label: 'Delete my account',
title: 'Account deletion request',
prefill: `Team ID:
Confirm you want all account data permanently deleted (yes / no):
Anything else we should know: `,
},
{
id: 'change_account_owner',
label: 'Change account owner or team email',
title: 'Account owner change',
prefill: `Team ID:
Current owner email:
New owner email:
Confirm the new owner has already created an E2B account with the new email (yes / no): `,
},
{
id: 'increase_limit',
label: 'Increase a limit (concurrency, RAM, disk, build)',
title: 'Limit increase request',
prefill: `Team ID:
Which limit you want raised (concurrent sandboxes, RAM per sandbox, disk per sandbox, concurrent builds):
Current value:
Target value:
Brief use case: `,
},
{
id: 'volumes_access',
label: 'Volumes access',
title: 'Volumes access request',
prefill: `Team ID:
What you plan to use volumes for:
Approximate volume size and access pattern (e.g. 50 GB read-mostly, shared across sandboxes):
Region preference (currently us-west1 only): `,
},
{
id: 'eu_cluster',
label: 'EU cluster or data residency',
title: 'EU cluster / data residency request',
prefill: `Team ID:
Specific data residency requirement (region, regulation, customer ask):
Expected sandbox volume per month:
Timeline: `,
},
{
id: 'enterprise_inquiry',
label: 'Enterprise inquiry',
title: 'Enterprise inquiry',
prefill: `Company:
Use case:
Current scale (sandboxes per day or month):
Expected scale at 6 months:
Timeline: `,
},
{
id: 'self_hosting',
label: 'Self-hosting / BYOC',
title: 'Self-hosting / BYOC inquiry',
prefill: `Company:
Cloud provider you want to deploy on:
Why self-hosted is required (compliance, data residency, latency, etc.):
Expected scale: `,
},
{
id: 'startup_program',
label: 'Startup program',
title: 'Startup program inquiry',
prefill: `Company:
Stage (pre-seed, seed, Series A, etc.):
Current E2B usage (if any):
What you are building: `,
},
{
id: 'something_else',
label: 'Something else',
title: 'Support Request',
prefill: '',
},
] as const

export type SupportTemplate = (typeof SUPPORT_TEMPLATES)[number]
export type SupportTemplateId = SupportTemplate['id']

export const SUPPORT_TEMPLATE_IDS = SUPPORT_TEMPLATES.map((t) => t.id) as [
SupportTemplateId,
...SupportTemplateId[],
]

export const DEFAULT_SUPPORT_TEMPLATE_ID: SupportTemplateId = 'something_else'

export function getSupportTemplate(id: SupportTemplateId): SupportTemplate {
const template = SUPPORT_TEMPLATES.find((t) => t.id === id)
if (!template) {
throw new Error(`Unknown support template id: ${id}`)
}
return template
}
11 changes: 11 additions & 0 deletions src/core/server/api/routers/support.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { createSupportRepository } from '@/core/modules/support/repository.server'
import {
getSupportTemplate,
SUPPORT_TEMPLATE_IDS,
} from '@/core/modules/support/templates'
import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors'
import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository'
import { createTRPCRouter } from '@/core/server/trpc/init'
Expand All @@ -24,6 +28,7 @@ export const supportRouter = createTRPCRouter({
z.object({
description: z.string().min(1),
files: z.array(FileSchema).max(5).optional(),
templateId: z.enum(SUPPORT_TEMPLATE_IDS).optional(),
})
)
.mutation(async ({ ctx, input }) => {
Expand All @@ -47,6 +52,11 @@ export const supportRouter = createTRPCRouter({
throwTRPCErrorFromRepoError(teamResult.error)
}

const templateTitle =
input.templateId && input.templateId !== 'something_else'
? getSupportTemplate(input.templateId).title
: undefined

const createResult = await ctx.supportRepository.createSupportThread({
description: input.description,
files: input.files,
Expand All @@ -55,6 +65,7 @@ export const supportRouter = createTRPCRouter({
customerEmail: email,
accountOwnerEmail: teamResult.data.email,
customerTier: teamResult.data.tier,
titleOverride: templateTitle,
})
if (!createResult.ok) {
throwTRPCErrorFromRepoError(createResult.error)
Expand Down
72 changes: 70 additions & 2 deletions src/features/dashboard/navbar/report-issue-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
import { useMutation } from '@tanstack/react-query'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import {
DEFAULT_SUPPORT_TEMPLATE_ID,
getSupportTemplate,
SUPPORT_TEMPLATE_IDS,
SUPPORT_TEMPLATES,
type SupportTemplateId,
} from '@/core/modules/support/templates'
import { useDashboard } from '@/features/dashboard/context'
import { useTRPC } from '@/trpc/client'
import { Button } from '@/ui/primitives/button'
Expand All @@ -29,6 +36,13 @@
FormMessage,
} from '@/ui/primitives/form'
import { CloseIcon, FileIcon } from '@/ui/primitives/icons'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/primitives/select'
import { Textarea } from '@/ui/primitives/textarea'
import FileDropZone from './file-drop-zone'

Expand All @@ -41,6 +55,7 @@

const supportFormSchema = z.object({
description: z.string().min(1, 'Please describe how we can help'),
templateId: z.enum(SUPPORT_TEMPLATE_IDS),
})

type SupportFormValues = z.infer<typeof supportFormSchema>
Expand Down Expand Up @@ -75,6 +90,7 @@
const [isOpen, setIsOpen] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const [files, setFiles] = useState<File[]>([])
const lastPrefillRef = useRef<string>('')

// Auto-open dialog when ?support=true is in the URL
useEffect(() => {
Expand All @@ -93,17 +109,19 @@
resolver: zodResolver(supportFormSchema),
defaultValues: {
description: '',
templateId: DEFAULT_SUPPORT_TEMPLATE_ID,
},
})

const contactSupportMutation = useMutation(
trpc.support.contactSupport.mutationOptions({
onSuccess: (data) => {
onSuccess: (data, variables) => {
posthog.capture('support_request_submitted', {
thread_id: data?.threadId,
team_id: team.id,
tier: team.tier,
attachment_count: files.length,
template_id: variables.templateId ?? DEFAULT_SUPPORT_TEMPLATE_ID,
})
setWasSubmitted(true)
toast.success(
Expand All @@ -126,8 +144,25 @@
const resetForm = useCallback(() => {
form.reset()
setFiles([])
lastPrefillRef.current = ''
}, [form])

const handleTemplateChange = useCallback(
(nextId: SupportTemplateId) => {
const nextPrefill = getSupportTemplate(nextId).prefill
const current = form.getValues('description')
if (current === '' || current === lastPrefillRef.current) {
form.setValue('description', nextPrefill, {
shouldValidate: true,
shouldDirty: nextPrefill.length > 0,
})
}
lastPrefillRef.current = nextPrefill
form.setValue('templateId', nextId, { shouldValidate: true })
},
[form]

Check warning on line 163 in src/features/dashboard/navbar/report-issue-dialog.tsx

View check run for this annotation

Claude / Claude Code Review

Switching back to "Something else" surfaces validation error before user input

When a user picks a non-default template (so the prefill is applied) and then switches back to **Something else**, `handleTemplateChange` runs `form.setValue('description', '', { shouldValidate: true })`. The empty string immediately fails the `description.min(1)` rule and `FormMessage` renders *Please describe how we can help* under the textarea before the user has typed anything. Consider passing `shouldValidate: false` (or skipping the `setValue` call) when transitioning to the empty default
Comment on lines 149 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 When a user picks a non-default template (so the prefill is applied) and then switches back to Something else, handleTemplateChange runs form.setValue('description', '', { shouldValidate: true }). The empty string immediately fails the description.min(1) rule and FormMessage renders Please describe how we can help under the textarea before the user has typed anything. Consider passing shouldValidate: false (or skipping the setValue call) when transitioning to the empty default — the field will still be invalid, so the submit button stays disabled, but the error message won't appear until the user has actually had a chance to type.

Extended reasoning...

What happens

In report-issue-dialog.tsx:149-163, handleTemplateChange runs:

if (current === '' || current === lastPrefillRef.current) {
  form.setValue('description', nextPrefill, {
    shouldValidate: true,
    shouldDirty: nextPrefill.length > 0,
  })
}

When nextPrefill is the empty string (only something_else has prefill: '') and the second branch (current === lastPrefillRef.current) is hit, the description gets cleared with shouldValidate: true. RHF runs the resolver synchronously, the zod description.min(1, 'Please describe how we can help') rule fails, and formState.errors.description is populated.

Why the message renders

The shared FormMessage (src/ui/primitives/form.tsx) renders any error in fieldState unconditionally — it does not gate on touched or dirty. So the moment shouldValidate: true produces an error, the textarea grows a Please describe how we can help line under it, even though the user has not typed anything in this category and just used the picker.

Step-by-step proof

  1. Open dialog: description = '', templateId = 'something_else', lastPrefillRef = ''. No error (RHF default mode is onSubmit, nothing has triggered validation).
  2. Pick Issue with a sandbox: current === '' → setValue with the sandbox prefill. shouldValidate: true validates, but the prefill is non-empty so min(1) passes. lastPrefillRef.current becomes the sandbox prefill.
  3. Pick Something else: nextPrefill = '', current === lastPrefillRef.current is true, so form.setValue('description', '', { shouldValidate: true }) runs. min(1) fails → formState.errors.description is set → FormMessage renders the error under the still-empty textarea.

Addressing the counter-argument

It is true that (a) submission relies on form.formState.isValid, so the field must validate, and (b) eager inline validation is not by itself an anti-pattern. The issue is specifically the timing: the error surfaces in response to a category change, not in response to user input on that field. The user has not had any opportunity to enter content for the new category before being told it is missing. That distinguishes it from the typical eager-validation cases (validate on blur, on submit, or after first keystroke).

Fix

Either pass shouldValidate: false when applying an empty prefill, or skip the setValue entirely when nextPrefill === '' (and clear by other means if needed). The form will remain invalid (the existing !form.formState.isValid guard already disables the Send button regardless of whether the resolver has run), but the visible error won't appear until the user actually engages with the textarea. Marking as nit — minor visible UX papercut, no functional/correctness impact.

)

const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
Expand Down Expand Up @@ -184,6 +219,7 @@
teamSlug: team.slug,
description,
files: filePayloads.length > 0 ? filePayloads : undefined,
templateId: values.templateId,
})
}

Expand Down Expand Up @@ -225,6 +261,38 @@
)}
/>

<FormField
control={form.control}
name="templateId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs text-fg-secondary">
What's this about? (optional)
</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
handleTemplateChange(value as SupportTemplateId)
}
disabled={isSubmitting}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SUPPORT_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>

<div className="flex flex-col gap-2">
<FileDropZone
onFilesSelected={handleFilesSelected}
Expand Down
Loading