Support form Assistant (#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>
This commit is contained in:
Saxon FletcherandGildas Garcia authored and GitHub committed 2026-05-21 09:16:48 +10:00
1 parent f0afc5948b
commit af7d953f52
19 files changed
+828 -80

No files matched your search

@@ -88,7 +88,7 @@ function CategorySelector({ form }: CategorySelectorProps) {
field.onChange(v)
}
return (
<FormItemLayout hideMessage layout="vertical" label="What are you having issues with?">
<FormItemLayout hideMessage layout="vertical" label="What issue are you having?">
<FormControl>
<Select {...fieldWithoutRef} defaultValue={field.value} onValueChange={onValueChange}>
<SelectTrigger aria-label="Select an issue" className="w-full">
@@ -54,7 +54,7 @@ interface ProjectSelectorProps {
}
function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
const { projectRef: urlProjectRef } = useParams()
const { ref: routeProjectRef } = useParams()
return (
<FormField
@@ -71,8 +71,13 @@ function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
slug={!orgSlug || orgSlug === NO_ORG_MARKER ? undefined : orgSlug}
selectedRef={field.value}
onInitialLoad={(projects) => {
if (!urlProjectRef && (!projectRef || projectRef === NO_PROJECT_MARKER))
const hasSelectedProject = !!projectRef && projectRef !== NO_PROJECT_MARKER
const hasRouteProjectInList =
!!routeProjectRef && projects.some((project) => project.ref === routeProjectRef)
if (!hasRouteProjectInList && !hasSelectedProject) {
field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
}
}}
onSelect={(project) => field.onChange(project.ref)}
renderTrigger={({ isLoading, project, listboxId, open }) => {
@@ -1,6 +1,6 @@
import { Check, Mail } from 'lucide-react'
import { Check } from 'lucide-react'
import Link from 'next/link'
import { Button, IconDiscord, Separator } from 'ui'
import { Button, IconDiscord } from 'ui'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
@@ -11,13 +11,15 @@ interface SuccessProps {
selectedProject?: string
onFinish?: () => void
finishLabel?: string
showFinishAction?: boolean
}
export const Success = ({
sentCategory = '',
sentCategory: _sentCategory = '',
selectedProject = NO_PROJECT_MARKER,
onFinish,
finishLabel = 'Finish',
showFinishAction = true,
}: SuccessProps) => {
const { profile } = useProfile()
const respondToEmail = profile?.primary_email ?? 'your email'
@@ -28,67 +30,47 @@ export const Success = ({
)
const projectName = project ? project.name : 'No specific project'
const categoriesToShowAdditionalResources = ['Problem', 'Unresponsive', 'Performance']
const finishAction = showFinishAction ? (
onFinish ? (
<Button type="default" onClick={onFinish}>
{finishLabel}
</Button>
) : (
<Button asChild type="default">
<Link href="/">{finishLabel}</Link>
</Button>
)
) : null
return (
<div className="mt-10 max-w-[620px] flex flex-col items-center space-y-4">
<div className="relative">
<Mail strokeWidth={1.5} size={32} className="text-brand" />
<div className="absolute -bottom-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-brand">
<Check strokeWidth={4} size={16} className="text-contrast" />
</div>
</div>
<div className="flex items-center flex-col space-y-2 text-center p-4">
<h3 className="text-xl">Support request sent</h3>
<div className="flex w-full flex-col items-center gap-4 px-4 pt-4 text-center">
<Check strokeWidth={1.5} size={24} className="text-brand" />
<p className="text-sm text-foreground-light text-balance">
<div className="flex max-w-[620px] flex-col items-center gap-2">
<h3 className="text-xl">Support request sent</h3>
<p className="text-balance text-sm text-foreground-light">
{selectedProject !== NO_PROJECT_MARKER && (
<>
Your ticket has been logged for the project{' '}
<span className="text-foreground font-medium">{projectName}</span> with project ID:{' '}
<span className="text-foreground font-medium">{selectedProject}</span>.
Your ticket has been logged for{' '}
<span className="font-medium text-foreground">{projectName}</span>.{' '}
</>
)}{' '}
We will reach out to you at{' '}
<span className="text-foreground font-medium">{respondToEmail}</span>.
)}
We&apos;ll reach out at{' '}
<span className="font-medium text-foreground">{respondToEmail}</span>.
</p>
</div>
{categoriesToShowAdditionalResources.includes(sentCategory) && (
<>
<div className="my-10! w-full">
<Separator />
</div>
<div className="flex flex-col items-center px-12 space-y-2 text-center">
<h4 className="text-lg font-normal">Tap into our community</h4>
<p className="text-sm text-foreground-light text-balance">
Our Discord community can help with code-related issues. Many questions are answered
in minutes.
</p>
</div>
<Button
asChild
type="default"
icon={<IconDiscord size={16} fill="hsl(var(--background-default))" />}
>
<Link href={'https://discord.supabase.com/'} target="_blank">
Join us on Discord
</Link>
</Button>
</>
)}
<div className="mt-10! w-full">
<Separator />
</div>
<div className="w-full pb-4 px-4 flex items-center justify-end">
{onFinish ? (
<Button type="default" onClick={onFinish}>
{finishLabel}
</Button>
) : (
<Button asChild type="default">
<Link href="/">{finishLabel}</Link>
</Button>
)}
<div className="flex flex-wrap items-center justify-center gap-3">
{finishAction}
<Button
asChild
type="default"
icon={<IconDiscord size={16} fill="hsl(var(--background-default))" />}
>
<Link href="https://discord.supabase.com/" target="_blank">
Join Discord
</Link>
</Button>
</div>
</div>
)
@@ -0,0 +1,68 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { describe, expect, it } from 'vitest'
import { buildSupportAssistantPrompt, parseSupportAssistantPrompt } from './SupportAssistant.utils'
import type { SubmittedSupportRequest } from './SupportForm.state'
const supportRequest: SubmittedSupportRequest = {
organizationSlug: 'org-1',
projectRef: 'project-1',
category: SupportCategories.PROBLEM,
severity: 'Normal',
subject: 'API requests fail',
message: 'Requests fail with <500> & timeouts',
affectedServices: 'api;database',
library: 'javascript',
allowSupportAccess: true,
dashboardLogs: 'https://example.com/logs',
}
describe('SupportAssistant utils', () => {
it('formats support requests as tagged assistant prompts', () => {
const prompt = buildSupportAssistantPrompt(supportRequest)
expect(prompt).toContain('<support>')
expect(prompt).toContain('<assistant_context>')
expect(prompt).toContain('a human member of the Supabase Support team is already looking at it')
expect(prompt).toContain('<subject>API requests fail</subject>')
expect(prompt).not.toContain('<organization_slug>')
expect(prompt).not.toContain('<project_ref>')
})
it('parses and unescapes tagged assistant prompts', () => {
const parsed = parseSupportAssistantPrompt(buildSupportAssistantPrompt(supportRequest))
expect(parsed).toMatchObject({
category: 'Problem',
severity: 'Normal',
subject: 'API requests fail',
message: 'Requests fail with <500> & timeouts',
support_access: 'Granted',
dashboard_logs: 'Attached',
})
})
it('falls back when optional support request fields are missing', () => {
const parsed = parseSupportAssistantPrompt(
buildSupportAssistantPrompt({
...supportRequest,
organizationSlug: undefined,
projectRef: undefined,
library: undefined,
dashboardLogs: undefined,
allowSupportAccess: false,
})
)
expect(parsed).toMatchObject({
library: 'Not provided',
support_access: 'Not granted',
dashboard_logs: 'Not attached',
})
})
it('returns null for text without a valid support payload', () => {
expect(parseSupportAssistantPrompt('Help me debug this issue')).toBeNull()
expect(parseSupportAssistantPrompt('<support></support>')).toBeNull()
})
})
@@ -0,0 +1,77 @@
import type { SubmittedSupportRequest } from './SupportForm.state'
const SUPPORT_ASSISTANT_FIELD_LABELS = {
assistant_context: 'Assistant context',
organization_slug: 'Organization',
project_ref: 'Project',
category: 'Category',
severity: 'Severity',
subject: 'Subject',
message: 'Message',
affected_services: 'Affected services',
library: 'Client library',
support_access: 'Support access',
dashboard_logs: 'Dashboard logs',
} as const
export type ParsedSupportAssistantPrompt = Partial<
Record<keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS, string>
>
function escapeSupportTagValue(value: string | boolean | undefined) {
if (value === undefined || value === '') return 'Not provided'
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function unescapeSupportTagValue(value: string) {
return value.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim()
}
function supportField(
name: keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS,
value: string | boolean | undefined
) {
return ` <${name}>${escapeSupportTagValue(value)}</${name}>`
}
export function buildSupportAssistantPrompt(request: SubmittedSupportRequest) {
return [
'<support>',
supportField(
'assistant_context',
'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.'
),
supportField('category', request.category),
supportField('severity', request.severity),
supportField('subject', request.subject),
supportField('message', request.message),
supportField('affected_services', request.affectedServices),
supportField('library', request.library),
supportField('support_access', request.allowSupportAccess ? 'Granted' : 'Not granted'),
supportField('dashboard_logs', request.dashboardLogs ? 'Attached' : 'Not attached'),
'</support>',
].join('\n')
}
export function parseSupportAssistantPrompt(text: string): ParsedSupportAssistantPrompt | null {
const supportMatch = text.match(/<support>([\s\S]*?)<\/support>/i)
if (!supportMatch) return null
const parsed = Object.keys(SUPPORT_ASSISTANT_FIELD_LABELS).reduce<ParsedSupportAssistantPrompt>(
(acc, field) => {
const fieldMatch = supportMatch[1].match(
new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`, 'i')
)
if (fieldMatch) {
acc[field as keyof typeof SUPPORT_ASSISTANT_FIELD_LABELS] = unescapeSupportTagValue(
fieldMatch[1]
)
}
return acc
},
{}
)
return Object.keys(parsed).length > 0 ? parsed : null
}
@@ -0,0 +1,182 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportAssistantSuccessCard } from './SupportAssistantSuccessCard'
import type { SubmittedSupportRequest } from './SupportForm.state'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
const { chatInstances, mockNewChat, mockOpenSidebar, mockSelectChat, mockTrack, mockUseChat } =
vi.hoisted(() => ({
chatInstances: {} as Record<string, unknown>,
mockNewChat: vi.fn(),
mockOpenSidebar: vi.fn(),
mockSelectChat: vi.fn(),
mockTrack: vi.fn(),
mockUseChat: vi.fn(),
}))
vi.mock('@ai-sdk/react', () => ({
useChat: mockUseChat,
}))
vi.mock('ai', () => ({
lastAssistantMessageIsCompleteWithToolCalls: vi.fn(),
}))
vi.mock('@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({
SIDEBAR_KEYS: {
AI_ASSISTANT: 'ai-assistant',
},
}))
vi.mock('@/state/ai-assistant-state', () => ({
useAiAssistantStateSnapshot: () => ({
chatInstances,
newChat: mockNewChat,
selectChat: mockSelectChat,
}),
}))
vi.mock('@/state/sidebar-manager-state', () => ({
useSidebarManagerSnapshot: () => ({
openSidebar: mockOpenSidebar,
}),
}))
vi.mock('@/lib/telemetry/track', () => ({
useTrack: () => mockTrack,
}))
vi.mock('@/components/ui/AIAssistantPanel/Message', () => ({
Message: ({ message }: { message: { parts?: Array<{ type: string; text?: string }> } }) => (
<div data-testid="assistant-preview-message">
{message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')}
</div>
),
}))
const supportRequest: SubmittedSupportRequest = {
organizationSlug: 'org-1',
projectRef: 'project-1',
category: SupportCategories.PROBLEM,
severity: 'Normal',
subject: 'API requests fail',
message: 'Requests fail with 500s',
affectedServices: 'api',
library: 'javascript',
allowSupportAccess: true,
dashboardLogs: undefined,
}
describe('SupportAssistantSuccessCard', () => {
beforeEach(() => {
Object.keys(chatInstances).forEach((key) => delete chatInstances[key])
mockNewChat.mockReset()
mockOpenSidebar.mockReset()
mockSelectChat.mockReset()
mockTrack.mockReset()
mockUseChat.mockReset()
mockNewChat.mockImplementation(() => {
chatInstances['chat-1'] = { id: 'chat-1' }
return 'chat-1'
})
mockUseChat.mockReturnValue({
messages: [],
status: 'submitted',
})
})
it('creates an assistant chat with the submitted support request', async () => {
render(<SupportAssistantSuccessCard request={supportRequest} />)
await waitFor(() => {
expect(mockNewChat).toHaveBeenCalledTimes(1)
})
expect(mockNewChat).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Support request',
initialMessage: expect.stringContaining('<support>'),
})
)
expect(mockNewChat.mock.calls[0]?.[0].initialMessage).toContain(
'A support request has already been submitted'
)
})
it('shows a loading preview before the assistant responds', async () => {
const { container } = render(<SupportAssistantSuccessCard request={supportRequest} />)
expect(await screen.findByRole('heading', { name: 'While you wait' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /open assistant response/i })).toBeInTheDocument()
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
})
it('renders a truncated assistant response preview', async () => {
const longResponse = 'A'.repeat(500)
mockUseChat.mockReturnValue({
messages: [
{
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text: longResponse }],
},
],
status: 'streaming',
})
render(<SupportAssistantSuccessCard request={supportRequest} />)
const preview = await screen.findByTestId('assistant-preview-message')
expect(preview).toHaveTextContent(`${'A'.repeat(420)}...`)
expect(preview).not.toHaveTextContent('A'.repeat(421))
})
it('opens the generated assistant chat when the action is clicked', async () => {
const user = userEvent.setup()
render(<SupportAssistantSuccessCard request={supportRequest} />)
const button = await screen.findByRole('button', { name: /open assistant response/i })
await user.click(button)
expect(mockTrack).toHaveBeenCalledWith(
'support_assistant_follow_up_card_clicked',
{ ticketCategory: SupportCategories.PROBLEM },
{
project: 'project-1',
organization: 'org-1',
}
)
expect(mockSelectChat).toHaveBeenCalledWith('chat-1')
expect(mockOpenSidebar).toHaveBeenCalledWith('ai-assistant')
})
it('opens the generated assistant chat with keyboard activation', async () => {
const user = userEvent.setup()
render(<SupportAssistantSuccessCard request={supportRequest} />)
const button = await screen.findByRole('button', { name: /open assistant response/i })
button.focus()
await user.keyboard('{Enter}')
expect(mockSelectChat).toHaveBeenCalledWith('chat-1')
expect(mockOpenSidebar).toHaveBeenCalledWith('ai-assistant')
})
it('does not render or create a chat when no project is selected', () => {
render(
<SupportAssistantSuccessCard
request={{ ...supportRequest, projectRef: NO_PROJECT_MARKER, organizationSlug: 'org-1' }}
/>
)
expect(screen.queryByText(/assistant response/i)).not.toBeInTheDocument()
expect(mockNewChat).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,220 @@
import { useChat, type UIMessage as MessageType } from '@ai-sdk/react'
import { lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { ArrowUpRight } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AiIconAnimation,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
cn,
Skeleton,
} from 'ui'
import { buildSupportAssistantPrompt } from './SupportAssistant.utils'
import type { SubmittedSupportRequest } from './SupportForm.state'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { Message } from '@/components/ui/AIAssistantPanel/Message'
import { useTrack } from '@/lib/telemetry/track'
import { useAiAssistantStateSnapshot, type AiAssistantState } from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
const SUPPORT_ASSISTANT_PREVIEW_MAX_CHARACTERS = 420
type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string]
interface SupportAssistantSuccessCardProps {
request: SubmittedSupportRequest
className?: string
}
function hasProjectScopedAssistantContext(projectRef: string | undefined) {
return projectRef !== undefined && projectRef !== NO_PROJECT_MARKER
}
export function SupportAssistantSuccessCard({
request,
className,
}: SupportAssistantSuccessCardProps) {
const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef)
const aiAssistant = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const track = useTrack()
const createdChatIdRef = useRef<string>(null)
const [chatId, setChatId] = useState<string>()
const chat = chatId ? aiAssistant.chatInstances[chatId] : undefined
const assistantPrompt = useMemo(() => buildSupportAssistantPrompt(request), [request])
useEffect(() => {
if (!hasAssistantContext) return
if (createdChatIdRef.current) return
const newChatId = aiAssistant.newChat({
name: 'Support request',
initialMessage: assistantPrompt,
})
createdChatIdRef.current = newChatId
setChatId(newChatId)
}, [aiAssistant, assistantPrompt, hasAssistantContext])
const handleOpenAssistant = () => {
track(
'support_assistant_follow_up_card_clicked',
{ ticketCategory: request.category },
{
project: request.projectRef,
organization: request.organizationSlug,
}
)
if (chatId) {
aiAssistant.selectChat(chatId)
}
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
}
if (!hasAssistantContext) return null
return (
<Card
role="button"
tabIndex={0}
aria-label="Open assistant response"
onClick={handleOpenAssistant}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleOpenAssistant()
}
}}
className={cn(
'group cursor-pointer bg-muted/50 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand',
className
)}
>
<CardHeader className="flex-row items-center justify-between gap-4 space-y-0">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
<AiIconAnimation size={14} />
</div>
<div className="min-w-0 space-y-1">
<CardTitle>While you wait</CardTitle>
<CardDescription>Assistant may be able to help</CardDescription>
</div>
</div>
<ArrowUpRight
size={14}
strokeWidth={1.5}
className="shrink-0 text-foreground-lighter transition-colors group-hover:text-foreground"
aria-hidden
/>
</CardHeader>
{chatId && chat ? (
<SupportAssistantResponsePreview
chatId={chatId}
chat={chat as SupportAssistantPreviewChat}
maxCharacters={SUPPORT_ASSISTANT_PREVIEW_MAX_CHARACTERS}
/>
) : (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)}
</Card>
)
}
function truncateAssistantMessage(message: MessageType, maxCharacters: number) {
let remainingCharacters = maxCharacters
let wasTruncated = false
const truncatedParts = message.parts?.flatMap((part) => {
if (part.type !== 'text') return []
if (remainingCharacters <= 0) {
wasTruncated = true
return []
}
const text = part.text.slice(0, remainingCharacters)
remainingCharacters -= text.length
if (text.length < part.text.length) {
wasTruncated = true
}
return [{ ...part, text: wasTruncated ? `${text.trimEnd()}...` : text }]
})
return {
message: { ...message, parts: truncatedParts },
wasTruncated,
}
}
function SupportAssistantResponsePreview({
chatId,
chat,
maxCharacters,
}: {
chatId: string
chat: SupportAssistantPreviewChat
maxCharacters: number
}) {
const { messages, status } = useChat({
id: chatId,
chat,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
})
const isChatLoading = status === 'submitted' || status === 'streaming'
const latestAssistantMessage = [...messages]
.reverse()
.find((message) => message.role === 'assistant')
if (!latestAssistantMessage) {
return (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)
}
const { message: previewMessage, wasTruncated } = truncateAssistantMessage(
latestAssistantMessage,
maxCharacters
)
return (
<CardContent className="relative max-h-48 overflow-hidden">
<Message
id={previewMessage.id}
message={previewMessage}
isLoading={isChatLoading}
readOnly
onDelete={() => {}}
onEdit={() => {}}
onCancelEdit={() => {}}
isAfterEditedMessage={false}
isBeingEdited={false}
/>
{wasTruncated && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-muted to-transparent" />
)}
</CardContent>
)
}
function SupportAssistantResponseLoadingSkeleton() {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-[82%]" />
<Skeleton className="h-4 w-[92%]" />
<Skeleton className="h-4 w-[68%]" />
</div>
)
}
@@ -1,6 +1,17 @@
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
import { neverGuard } from '@/lib/helpers'
export type SubmittedSupportRequest = Pick<
SupportFormValues,
'category' | 'severity' | 'subject' | 'message' | 'affectedServices' | 'allowSupportAccess'
> & {
organizationSlug: string | undefined
projectRef: string | undefined
library?: string
dashboardLogs?: string
}
export type SupportFormState =
| {
type: 'initializing'
@@ -16,6 +27,7 @@ export type SupportFormState =
sentProjectRef: string | undefined
sentOrgSlug: string | undefined
sentCategory: ExtendedSupportCategories
submittedRequest: SubmittedSupportRequest
}
| {
type: 'error'
@@ -30,6 +42,7 @@ export type SupportFormActions =
sentProjectRef: string | undefined
sentOrgSlug: string | undefined
sentCategory: ExtendedSupportCategories
submittedRequest: SubmittedSupportRequest
debugSource?: string
}
| { type: 'ERROR'; message: string; debugSource?: string }
@@ -69,6 +82,7 @@ export function supportFormReducer(
sentProjectRef: action.sentProjectRef,
sentOrgSlug: action.sentOrgSlug,
sentCategory: action.sentCategory,
submittedRequest: action.submittedRequest,
}
}
if (action.type === 'ERROR') {
@@ -96,6 +96,18 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
sentProjectRef: variables.projectRef,
sentOrgSlug: variables.organizationSlug,
sentCategory: variables.category,
submittedRequest: {
organizationSlug: variables.organizationSlug,
projectRef: variables.projectRef,
category: variables.category,
severity: variables.severity,
subject: variables.subject,
message: variables.message,
affectedServices: variables.affectedServices ?? '',
library: variables.library,
allowSupportAccess: variables.allowSupportAccess,
dashboardLogs: variables.dashboardLogs,
},
})
},
onError: (error) => {
@@ -104,6 +104,18 @@ export const SupportFormV3 = ({
sentProjectRef: variables.projectRef,
sentOrgSlug: variables.organizationSlug,
sentCategory: variables.category,
submittedRequest: {
organizationSlug: variables.organizationSlug,
projectRef: variables.projectRef,
category: variables.category,
severity: variables.severity,
subject: variables.subject,
message: variables.message,
affectedServices: variables.affectedServices ?? '',
library: variables.library,
allowSupportAccess: variables.allowSupportAccess,
dashboardLogs: variables.dashboardLogs,
},
})
},
onError: (error) => {
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/nextjs'
import { useFlag } from 'common'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useReducer } from 'react'
@@ -8,8 +9,9 @@ import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { IncidentAdmonition } from './IncidentAdmonition'
import { Success } from './Success'
import type { ExtendedSupportCategories } from './Support.constants'
import { SupportAssistantSuccessCard } from './SupportAssistantSuccessCard'
import { createInitialSupportFormState, supportFormReducer } from './SupportForm.state'
import type { SupportFormUrlKeys } from './SupportForm.utils'
import { NO_PROJECT_MARKER, type SupportFormUrlKeys } from './SupportForm.utils'
import { SupportFormV3 } from './SupportFormV3'
import { useSupportForm } from './useSupportForm'
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
@@ -45,12 +47,12 @@ function useSupportFormTelemetry() {
interface SupportFormProps {
initialParams?: Partial<SupportFormUrlKeys>
onFinish?: () => void
}
export function SupportForm({ initialParams, onFinish }: SupportFormProps) {
export function SupportForm({ initialParams }: SupportFormProps) {
const [state, dispatch] = useReducer(supportFormReducer, undefined, createInitialSupportFormState)
const { form, initialError, projectRef } = useSupportForm(dispatch, initialParams)
const showSupportAssistantFollowUp = useFlag('supportAssistantFollowUp') === true
const {
data: allStatusPageEvents,
@@ -77,7 +79,12 @@ export function SupportForm({ initialParams, onFinish }: SupportFormProps) {
dispatch({ type: 'RETURN_TO_EDITING' })
})
const isSuccess = state.type === 'success'
const successState = state.type === 'success' ? state : null
const showAssistantSuccessCard =
showSupportAssistantFollowUp &&
successState !== null &&
successState.submittedRequest.projectRef !== undefined &&
successState.submittedRequest.projectRef !== NO_PROJECT_MARKER
return (
<div className="relative h-full overflow-y-auto overflow-x-hidden">
@@ -87,14 +94,20 @@ export function SupportForm({ initialParams, onFinish }: SupportFormProps) {
/>
<div className="min-h-full px-5 pt-5">
<div className="flex flex-col gap-y-8">
{isSuccess ? (
<div className="pt-2">
{successState ? (
<div className="flex flex-col gap-y-8 pt-2">
<Success
selectedProject={projectRef ?? undefined}
sentCategory={state.sentCategory}
onFinish={onFinish}
finishLabel={onFinish ? 'Done' : undefined}
selectedProject={
successState.sentProjectRef === undefined
? (projectRef ?? undefined)
: successState.sentProjectRef
}
sentCategory={successState.sentCategory}
showFinishAction={false}
/>
{showAssistantSuccessCard && (
<SupportAssistantSuccessCard request={successState.submittedRequest} />
)}
</div>
) : (
<SupportFormV3
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { cn } from 'ui'
interface SupportSuccessSectionProps {
icon?: ReactNode
title: string
description: ReactNode
action?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
}
export function SupportSuccessSection({
icon,
title,
description,
action,
children,
className,
headerClassName,
}: SupportSuccessSectionProps) {
const hasIcon = icon !== undefined
return (
<div className={cn('w-full py-1', className)}>
<div className="flex flex-col gap-4">
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between',
headerClassName
)}
>
<div className={cn('flex min-w-0 flex-1 items-start', hasIcon ? 'gap-3' : 'gap-0')}>
{hasIcon && (
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-background">
{icon}
</div>
)}
<div className="min-w-0 space-y-1">
<h4 className="text-sm font-medium text-foreground">{title}</h4>
<div className="text-sm text-foreground-light">{description}</div>
</div>
</div>
{action && <div className="shrink-0 sm:ml-4">{action}</div>}
</div>
{children && <div className={cn(hasIcon && 'sm:pl-[52px]')}>{children}</div>}
</div>
</div>
)
}
@@ -51,6 +51,12 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const [sidebarLocalStorage, setSidebarLocalStorage, { isSuccess: isLoadedLocalStorage }] =
useLocalStorageQuery(LOCAL_STORAGE_KEYS.LAST_OPENED_SIDE_BAR(project?.ref ?? ''), '')
const supportLinkQueryParams = getSupportLinkQueryParams(
project,
org,
router.query.ref as string | undefined
)
const sidebarURLParamRef = useLatest(sidebarURLParam)
const sidebarLocalStorageRef = useLatest(sidebarLocalStorage)
@@ -63,11 +69,7 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
<HelpPanel
onClose={() => closeSidebar(SIDEBAR_KEYS.HELP_PANEL)}
projectRef={project?.ref}
supportLinkQueryParams={getSupportLinkQueryParams(
project,
org,
router.query.ref as string | undefined
)}
supportLinkQueryParams={supportLinkQueryParams}
/>
),
{},
@@ -13,9 +13,15 @@ import {
parseExecuteSqlChartResult,
} from './Message.utils'
import { MessageMarkdown } from './MessageMarkdown'
import { parseSupportRequestMessage, SupportRequestMessage } from './SupportRequestMessage'
function MessagePartText({ textPart }: { textPart: TextUIPart }) {
const { id, isLoading, readOnly, isUserMessage, state } = useMessageInfoContext()
const supportRequest = isUserMessage ? parseSupportRequestMessage(textPart.text) : null
if (supportRequest) {
return <SupportRequestMessage request={supportRequest} />
}
return (
<MessageMarkdown
@@ -113,6 +113,7 @@ export function Message(props: MessageProps) {
isLoading: props.isLoading,
readOnly: props.readOnly,
variant: props.variant,
isUserMessage,
state: props.isBeingEdited
? 'editing'
: props.isAfterEditedMessage
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { parseSupportRequestMessage, SupportRequestMessage } from './SupportRequestMessage'
describe('SupportRequestMessage', () => {
it('parses support-tagged messages', () => {
expect(
parseSupportRequestMessage(`
<support>
<subject>Database unavailable</subject>
<message>Connections are timing out</message>
</support>
`)
).toMatchObject({
subject: 'Database unavailable',
message: 'Connections are timing out',
})
})
it('renders a structured support request summary', () => {
render(
<SupportRequestMessage
request={{
assistant_context:
'A support request has already been submitted and a human member of the Supabase Support team is already looking at it.',
subject: 'Database unavailable',
message: 'Connections are timing out',
organization_slug: 'org-1',
project_ref: 'project-1',
category: 'Problem',
severity: 'High',
support_access: 'Granted',
library: 'Not provided',
}}
/>
)
expect(screen.getByText('Support request submitted')).toBeInTheDocument()
expect(screen.getByText('Database unavailable')).toBeInTheDocument()
expect(screen.getByText('Connections are timing out')).toBeInTheDocument()
expect(screen.queryByText('org-1')).not.toBeInTheDocument()
expect(screen.queryByText('project-1')).not.toBeInTheDocument()
expect(screen.queryByText('Granted')).not.toBeInTheDocument()
expect(screen.queryByText('Client library')).not.toBeInTheDocument()
expect(screen.queryByText(/human member of the supabase support team/i)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,44 @@
import { Mail } from 'lucide-react'
import {
parseSupportAssistantPrompt,
type ParsedSupportAssistantPrompt,
} from '@/components/interfaces/Support/SupportAssistant.utils'
export function parseSupportRequestMessage(text: string) {
return parseSupportAssistantPrompt(text)
}
export function SupportRequestMessage({ request }: { request: ParsedSupportAssistantPrompt }) {
return (
<div className="not-prose rounded-lg border bg-surface-75 p-4 text-sm text-foreground-light">
<div className="min-w-0 space-y-4">
<div className="space-y-4">
<Mail size={16} strokeWidth={1.5} className="text-foreground-muted" />
<div className="space-y-1">
<p className="heading-default text-foreground">Support request submitted</p>
<p>
Supabase Support already has this ticket. Assistant is reviewing the same request to
help in the interim.
</p>
</div>
</div>
{request.subject && (
<div>
<p className="heading-meta text-foreground-light">Subject</p>
<p className="text-foreground">{request.subject}</p>
</div>
)}
{request.message && (
<div>
<p className="heading-meta text-foreground-light">Message</p>
<p className="line-clamp-6 whitespace-pre-wrap break-words text-foreground">
{request.message}
</p>
</div>
)}
</div>
</div>
)
}
@@ -66,12 +66,7 @@ export const HelpPanel = ({
</div>
<div className="flex-1 overflow-hidden">
{isSupportView ? (
<SupportForm
initialParams={supportLinkQueryParams}
onFinish={() => {
setView('home')
}}
/>
<SupportForm initialParams={supportLinkQueryParams} />
) : (
<div className="flex h-full flex-col overflow-y-auto pb-5">
<HelpSection
+16
View File
@@ -1460,6 +1460,21 @@ export interface AiAssistantInSupportFormClickedEvent {
groups: Partial<TelemetryGroups>
}
/**
* User clicked the Assistant follow-up card after submitting a support ticket.
*
* @group Events
* @source studio
* @page /dashboard/support/new
*/
export interface SupportAssistantFollowUpCardClickedEvent {
action: 'support_assistant_follow_up_card_clicked'
properties: {
ticketCategory: string
}
groups: Partial<TelemetryGroups>
}
/**
* User rated an AI assistant message with thumbs up or thumbs down.
*
@@ -3370,6 +3385,7 @@ export type TelemetryEvent =
| SupabaseUiCommandCopyButtonClickedEvent
| SupportTicketSubmittedEvent
| AiAssistantInSupportFormClickedEvent
| SupportAssistantFollowUpCardClickedEvent
| OrganizationMfaEnforcementUpdatedEvent
| ForeignDataWrapperCreatedEvent
| StorageBucketCreatedEvent