Skip to content

Commit af7d953

Browse files
SaxonFdjhi
andauthored
Support form Assistant (supabase#45861)
When a support from is submitted, we believe there is an opportunity to help people before a human receives and responds. Human support is still involved regardless of whether Assistant helps or not, so this is to positioned as a "while you wait" type experience. ## To test: - Enable to `supportAssistantFollowUp` feature flag - Open a project - Open the support form and submit a request - Note the success state and the additional Assistant card - Note text generation in card - Clicking card should open the Assistant conversation <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * AI assistant follow-up card appears after submitting a support ticket to continue the conversation * Support request preview rendered in the assistant panel showing subject/message when present * **Bug Fixes** * Improved project selection fallback during support form initialization * **Improvements** * Refined success layout and messaging; finish action behavior simplified [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45861) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
1 parent f0afc59 commit af7d953

19 files changed

Lines changed: 828 additions & 80 deletions

apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function CategorySelector({ form }: CategorySelectorProps) {
8888
field.onChange(v)
8989
}
9090
return (
91-
<FormItemLayout hideMessage layout="vertical" label="What are you having issues with?">
91+
<FormItemLayout hideMessage layout="vertical" label="What issue are you having?">
9292
<FormControl>
9393
<Select {...fieldWithoutRef} defaultValue={field.value} onValueChange={onValueChange}>
9494
<SelectTrigger aria-label="Select an issue" className="w-full">

apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ interface ProjectSelectorProps {
5454
}
5555

5656
function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
57-
const { projectRef: urlProjectRef } = useParams()
57+
const { ref: routeProjectRef } = useParams()
5858

5959
return (
6060
<FormField
@@ -71,8 +71,13 @@ function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
7171
slug={!orgSlug || orgSlug === NO_ORG_MARKER ? undefined : orgSlug}
7272
selectedRef={field.value}
7373
onInitialLoad={(projects) => {
74-
if (!urlProjectRef && (!projectRef || projectRef === NO_PROJECT_MARKER))
74+
const hasSelectedProject = !!projectRef && projectRef !== NO_PROJECT_MARKER
75+
const hasRouteProjectInList =
76+
!!routeProjectRef && projects.some((project) => project.ref === routeProjectRef)
77+
78+
if (!hasRouteProjectInList && !hasSelectedProject) {
7579
field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
80+
}
7681
}}
7782
onSelect={(project) => field.onChange(project.ref)}
7883
renderTrigger={({ isLoading, project, listboxId, open }) => {

apps/studio/components/interfaces/Support/Success.tsx

Lines changed: 38 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Check, Mail } from 'lucide-react'
1+
import { Check } from 'lucide-react'
22
import Link from 'next/link'
3-
import { Button, IconDiscord, Separator } from 'ui'
3+
import { Button, IconDiscord } from 'ui'
44

55
import { NO_PROJECT_MARKER } from './SupportForm.utils'
66
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
@@ -11,13 +11,15 @@ interface SuccessProps {
1111
selectedProject?: string
1212
onFinish?: () => void
1313
finishLabel?: string
14+
showFinishAction?: boolean
1415
}
1516

1617
export const Success = ({
17-
sentCategory = '',
18+
sentCategory: _sentCategory = '',
1819
selectedProject = NO_PROJECT_MARKER,
1920
onFinish,
2021
finishLabel = 'Finish',
22+
showFinishAction = true,
2123
}: SuccessProps) => {
2224
const { profile } = useProfile()
2325
const respondToEmail = profile?.primary_email ?? 'your email'
@@ -28,67 +30,47 @@ export const Success = ({
2830
)
2931
const projectName = project ? project.name : 'No specific project'
3032

31-
const categoriesToShowAdditionalResources = ['Problem', 'Unresponsive', 'Performance']
33+
const finishAction = showFinishAction ? (
34+
onFinish ? (
35+
<Button type="default" onClick={onFinish}>
36+
{finishLabel}
37+
</Button>
38+
) : (
39+
<Button asChild type="default">
40+
<Link href="/">{finishLabel}</Link>
41+
</Button>
42+
)
43+
) : null
3244

3345
return (
34-
<div className="mt-10 max-w-[620px] flex flex-col items-center space-y-4">
35-
<div className="relative">
36-
<Mail strokeWidth={1.5} size={32} className="text-brand" />
37-
<div className="absolute -bottom-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-brand">
38-
<Check strokeWidth={4} size={16} className="text-contrast" />
39-
</div>
40-
</div>
41-
<div className="flex items-center flex-col space-y-2 text-center p-4">
42-
<h3 className="text-xl">Support request sent</h3>
46+
<div className="flex w-full flex-col items-center gap-4 px-4 pt-4 text-center">
47+
<Check strokeWidth={1.5} size={24} className="text-brand" />
4348

44-
<p className="text-sm text-foreground-light text-balance">
49+
<div className="flex max-w-[620px] flex-col items-center gap-2">
50+
<h3 className="text-xl">Support request sent</h3>
51+
<p className="text-balance text-sm text-foreground-light">
4552
{selectedProject !== NO_PROJECT_MARKER && (
4653
<>
47-
Your ticket has been logged for the project{' '}
48-
<span className="text-foreground font-medium">{projectName}</span> with project ID:{' '}
49-
<span className="text-foreground font-medium">{selectedProject}</span>.
54+
Your ticket has been logged for{' '}
55+
<span className="font-medium text-foreground">{projectName}</span>.{' '}
5056
</>
51-
)}{' '}
52-
We will reach out to you at{' '}
53-
<span className="text-foreground font-medium">{respondToEmail}</span>.
57+
)}
58+
We&apos;ll reach out at{' '}
59+
<span className="font-medium text-foreground">{respondToEmail}</span>.
5460
</p>
5561
</div>
56-
{categoriesToShowAdditionalResources.includes(sentCategory) && (
57-
<>
58-
<div className="my-10! w-full">
59-
<Separator />
60-
</div>
61-
<div className="flex flex-col items-center px-12 space-y-2 text-center">
62-
<h4 className="text-lg font-normal">Tap into our community</h4>
63-
<p className="text-sm text-foreground-light text-balance">
64-
Our Discord community can help with code-related issues. Many questions are answered
65-
in minutes.
66-
</p>
67-
</div>
68-
<Button
69-
asChild
70-
type="default"
71-
icon={<IconDiscord size={16} fill="hsl(var(--background-default))" />}
72-
>
73-
<Link href={'https://discord.supabase.com/'} target="_blank">
74-
Join us on Discord
75-
</Link>
76-
</Button>
77-
</>
78-
)}
79-
<div className="mt-10! w-full">
80-
<Separator />
81-
</div>
82-
<div className="w-full pb-4 px-4 flex items-center justify-end">
83-
{onFinish ? (
84-
<Button type="default" onClick={onFinish}>
85-
{finishLabel}
86-
</Button>
87-
) : (
88-
<Button asChild type="default">
89-
<Link href="/">{finishLabel}</Link>
90-
</Button>
91-
)}
62+
63+
<div className="flex flex-wrap items-center justify-center gap-3">
64+
{finishAction}
65+
<Button
66+
asChild
67+
type="default"
68+
icon={<IconDiscord size={16} fill="hsl(var(--background-default))" />}
69+
>
70+
<Link href="https://discord.supabase.com/" target="_blank">
71+
Join Discord
72+
</Link>
73+
</Button>
9274
</div>
9375
</div>
9476
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { SupportCategories } from '@supabase/shared-types/out/constants'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { buildSupportAssistantPrompt, parseSupportAssistantPrompt } from './SupportAssistant.utils'
5+
import type { SubmittedSupportRequest } from './SupportForm.state'
6+
7+
const supportRequest: SubmittedSupportRequest = {
8+
organizationSlug: 'org-1',
9+
projectRef: 'project-1',
10+
category: SupportCategories.PROBLEM,
11+
severity: 'Normal',
12+
subject: 'API requests fail',
13+
message: 'Requests fail with <500> & timeouts',
14+
affectedServices: 'api;database',
15+
library: 'javascript',
16+
allowSupportAccess: true,
17+
dashboardLogs: 'https://example.com/logs',
18+
}
19+
20+
describe('SupportAssistant utils', () => {
21+
it('formats support requests as tagged assistant prompts', () => {
22+
const prompt = buildSupportAssistantPrompt(supportRequest)
23+
24+
expect(prompt).toContain('<support>')
25+
expect(prompt).toContain('<assistant_context>')
26+
expect(prompt).toContain('a human member of the Supabase Support team is already looking at it')
27+
expect(prompt).toContain('<subject>API requests fail</subject>')
28+
expect(prompt).not.toContain('<organization_slug>')
29+
expect(prompt).not.toContain('<project_ref>')
30+
})
31+
32+
it('parses and unescapes tagged assistant prompts', () => {
33+
const parsed = parseSupportAssistantPrompt(buildSupportAssistantPrompt(supportRequest))
34+
35+
expect(parsed).toMatchObject({
36+
category: 'Problem',
37+
severity: 'Normal',
38+
subject: 'API requests fail',
39+
message: 'Requests fail with <500> & timeouts',
40+
support_access: 'Granted',
41+
dashboard_logs: 'Attached',
42+
})
43+
})
44+
45+
it('falls back when optional support request fields are missing', () => {
46+
const parsed = parseSupportAssistantPrompt(
47+
buildSupportAssistantPrompt({
48+
...supportRequest,
49+
organizationSlug: undefined,
50+
projectRef: undefined,
51+
library: undefined,
52+
dashboardLogs: undefined,
53+
allowSupportAccess: false,
54+
})
55+
)
56+
57+
expect(parsed).toMatchObject({
58+
library: 'Not provided',
59+
support_access: 'Not granted',
60+
dashboard_logs: 'Not attached',
61+
})
62+
})
63+
64+
it('returns null for text without a valid support payload', () => {
65+
expect(parseSupportAssistantPrompt('Help me debug this issue')).toBeNull()
66+
expect(parseSupportAssistantPrompt('<support></support>')).toBeNull()
67+
})
68+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { SubmittedSupportRequest } from './SupportForm.state'
2+
3+
const SUPPORT_ASSISTANT_FIELD_LABELS = {
4+
assistant_context: 'Assistant context',
5+
organization_slug: 'Organization',
6+
project_ref: 'Project',
7+
category: 'Category',
8+
severity: 'Severity',
9+
subject: 'Subject',
10+
message: 'Message',
11+
affected_services: 'Affected services',
12+
library: 'Client library',
13+
support_access: 'Support access',
14+
dashboard_logs: 'Dashboard logs',
15+
} as const
16+
17+
export type ParsedSupportAssistantPrompt = Partial<
18+
Record<keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS, string>
19+
>
20+
21+
function escapeSupportTagValue(value: string | boolean | undefined) {
22+
if (value === undefined || value === '') return 'Not provided'
23+
24+
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
25+
}
26+
27+
function unescapeSupportTagValue(value: string) {
28+
return value.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim()
29+
}
30+
31+
function supportField(
32+
name: keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS,
33+
value: string | boolean | undefined
34+
) {
35+
return ` <${name}>${escapeSupportTagValue(value)}</${name}>`
36+
}
37+
38+
export function buildSupportAssistantPrompt(request: SubmittedSupportRequest) {
39+
return [
40+
'<support>',
41+
supportField(
42+
'assistant_context',
43+
'A support request has already been submitted and a human member of the Supabase Support team is already looking at it. Your role is to help the user troubleshoot in the interim while they wait for the human support response. Do not ask them to submit another support ticket for this same issue.'
44+
),
45+
supportField('category', request.category),
46+
supportField('severity', request.severity),
47+
supportField('subject', request.subject),
48+
supportField('message', request.message),
49+
supportField('affected_services', request.affectedServices),
50+
supportField('library', request.library),
51+
supportField('support_access', request.allowSupportAccess ? 'Granted' : 'Not granted'),
52+
supportField('dashboard_logs', request.dashboardLogs ? 'Attached' : 'Not attached'),
53+
'</support>',
54+
].join('\n')
55+
}
56+
57+
export function parseSupportAssistantPrompt(text: string): ParsedSupportAssistantPrompt | null {
58+
const supportMatch = text.match(/<support>([\s\S]*?)<\/support>/i)
59+
if (!supportMatch) return null
60+
61+
const parsed = Object.keys(SUPPORT_ASSISTANT_FIELD_LABELS).reduce<ParsedSupportAssistantPrompt>(
62+
(acc, field) => {
63+
const fieldMatch = supportMatch[1].match(
64+
new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`, 'i')
65+
)
66+
if (fieldMatch) {
67+
acc[field as keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS] = unescapeSupportTagValue(
68+
fieldMatch[1]
69+
)
70+
}
71+
return acc
72+
},
73+
{}
74+
)
75+
76+
return Object.keys(parsed).length > 0 ? parsed : null
77+
}

0 commit comments

Comments
 (0)