Support form Assistant Streamdown (#46248)

Re-adds support form Assistant response using a lighter weight
Streamdown component vs the more heavy `Message` component.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* AI Assistant follow-up card after ticket submission for project-scoped
requests.
* In-chat support request preview panels showing submitted subject and
message.

* **Improvements**
* Smarter project selection when opening the support form via
route/context.
* Success screen: cleaner layout, project-name messaging, optional
finish action, and a "Join Discord" button.
  * Category prompt text updated to "What issue are you having?"
  * New success/feedback section for consistent layouts.

* **Tests**
* Added tests covering support prompt serialization/parsing and UI
previews.

<!-- review_stack_entry_start -->

[![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/46248?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-05-26 09:56:52 +00:00
1 parent 2f24238fd9
commit 033daf223c
21 files changed
+924 -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,204 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubmittedSupportRequest } from './SupportForm.state'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
import { SupportAssistantSuccessCardContent as SupportAssistantSuccessCard } from '@/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent'
const { chatInstances, mockNewChat, mockOpenSidebar, mockSelectChat, mockTrack } = vi.hoisted(
() => ({
chatInstances: {} as Record<string, MockChat>,
mockNewChat: vi.fn(),
mockOpenSidebar: vi.fn(),
mockSelectChat: vi.fn(),
mockTrack: vi.fn(),
})
)
type MockChat = {
messages: Array<{ id: string; role: string; parts: Array<{ type: string; text: string }> }>
'~registerMessagesCallback': ReturnType<typeof vi.fn>
}
vi.mock('streamdown', () => ({
Streamdown: ({ children }: { children: string }) => (
<div data-testid="assistant-preview-message">{children}</div>
),
}))
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,
}))
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', () => {
let nextChatMessages: MockChat['messages']
let emitChatMessagesChange: (() => void) | undefined
function createMockChat(messages: MockChat['messages'] = []) {
return {
messages,
'~registerMessagesCallback': vi.fn((onStoreChange: () => void) => {
emitChatMessagesChange = onStoreChange
return vi.fn()
}),
}
}
beforeEach(() => {
Object.keys(chatInstances).forEach((key) => delete chatInstances[key])
mockNewChat.mockReset()
mockOpenSidebar.mockReset()
mockSelectChat.mockReset()
mockTrack.mockReset()
nextChatMessages = []
emitChatMessagesChange = undefined
mockNewChat.mockImplementation(() => {
chatInstances['chat-1'] = createMockChat(nextChatMessages)
return 'chat-1'
})
})
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 the full assistant response preview inside the clipped content area', async () => {
const longResponse = 'A'.repeat(500)
nextChatMessages = [
{
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text: longResponse }],
},
]
render(<SupportAssistantSuccessCard request={supportRequest} />)
const preview = await screen.findByTestId('assistant-preview-message')
expect(preview).toHaveTextContent(longResponse)
expect(preview.closest('[class*="max-h-48"]')).toHaveClass('overflow-hidden')
})
it('updates the preview when the shared chat receives an assistant message', async () => {
render(<SupportAssistantSuccessCard request={supportRequest} />)
await waitFor(() => {
expect(chatInstances['chat-1']?.['~registerMessagesCallback']).toHaveBeenCalled()
})
act(() => {
chatInstances['chat-1'].messages = [
{
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text: 'Try checking the API logs first.' }],
},
]
emitChatMessagesChange?.()
})
expect(await screen.findByTestId('assistant-preview-message')).toHaveTextContent(
'Try checking the API logs first.'
)
})
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,85 @@
import { ArrowUpRight } from 'lucide-react'
import dynamic from 'next/dynamic'
import {
AiIconAnimation,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
cn,
Skeleton,
} from 'ui'
import type { SubmittedSupportRequest } from './SupportForm.state'
import { NO_PROJECT_MARKER } from './SupportForm.utils'
interface SupportAssistantSuccessCardProps {
request: SubmittedSupportRequest
className?: string
}
const SupportAssistantSuccessCardContent = dynamic<SupportAssistantSuccessCardProps>(
() =>
import('@/components/ui/AIAssistantPanel/AIAssistant').then(
(mod) => mod.SupportAssistantSuccessCardContent
),
{
loading: () => <SupportAssistantSuccessCardLoadingShell />,
}
)
function hasProjectScopedAssistantContext(projectRef: string | undefined) {
return projectRef !== undefined && projectRef !== NO_PROJECT_MARKER
}
export function SupportAssistantSuccessCard(props: SupportAssistantSuccessCardProps) {
if (!hasProjectScopedAssistantContext(props.request.projectRef)) return null
return <SupportAssistantSuccessCardContent {...props} />
}
function SupportAssistantSuccessCardLoadingShell({ className }: { className?: string }) {
return (
<Card
role="button"
tabIndex={0}
aria-label="Open assistant response"
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>
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
</Card>
)
}
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}
/>
),
{},
@@ -601,3 +601,5 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
</ErrorBoundary>
)
}
export { SupportAssistantSuccessCardContent } from './SupportAssistantSuccessCardContent'
@@ -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,207 @@
import type { UIMessage as MessageType } from '@ai-sdk/react'
import { ArrowUpRight } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import type { JSX } from 'react'
import type { StreamdownProps } from 'streamdown'
import {
AiIconAnimation,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
cn,
Skeleton,
} from 'ui'
import { buildSupportAssistantPrompt } from '@/components/interfaces/Support/SupportAssistant.utils'
import type { SubmittedSupportRequest } from '@/components/interfaces/Support/SupportForm.state'
import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useTrack } from '@/lib/telemetry/track'
import { useAiAssistantStateSnapshot, type AiAssistantState } from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string]
const EMPTY_MESSAGES: MessageType[] = []
const Streamdown = dynamic<StreamdownProps>(
() => import('streamdown').then((mod) => mod.Streamdown),
{ ssr: false }
)
interface SupportAssistantSuccessCardContentProps {
request: SubmittedSupportRequest
className?: string
}
function hasProjectScopedAssistantContext(projectRef: string | undefined) {
return projectRef !== undefined && projectRef !== NO_PROJECT_MARKER
}
export function SupportAssistantSuccessCardContent({
request,
className,
}: SupportAssistantSuccessCardContentProps) {
const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef)
const aiAssistant = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const track = useTrack()
const createdChatIdRef = useRef<string | null>(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>
{chat ? (
<SupportAssistantResponsePreview chat={chat as SupportAssistantPreviewChat} />
) : (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)}
</Card>
)
}
function useChatMessages(chat: SupportAssistantPreviewChat | undefined) {
const subscribe = useCallback(
(onStoreChange: () => void) => {
return chat?.['~registerMessagesCallback']?.(onStoreChange) ?? (() => {})
},
[chat]
)
const getSnapshot = useCallback(() => chat?.messages ?? EMPTY_MESSAGES, [chat])
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
function getAssistantMessageText(message: MessageType) {
return (
message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('') ?? ''
)
}
function SupportAssistantResponsePreview({ chat }: { chat: SupportAssistantPreviewChat }) {
const messages = useChatMessages(chat)
const latestAssistantMessage = [...messages]
.reverse()
.find((message) => message.role === 'assistant')
if (!latestAssistantMessage) {
return (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)
}
const previewText = getAssistantMessageText(latestAssistantMessage)
return (
<CardContent className="relative max-h-48 overflow-hidden">
<SupportAssistantPreviewMarkdown>{previewText}</SupportAssistantPreviewMarkdown>
</CardContent>
)
}
function SupportAssistantPreviewMarkdown({ children }: { children: string }) {
return (
<Streamdown
className="prose prose-sm dark:prose-dark max-w-none space-y-3 text-sm text-foreground-light prose-p:my-0 prose-strong:font-medium prose-strong:text-foreground prose-code:text-xs prose-li:my-0 prose-ul:my-0 prose-ol:my-0"
components={supportAssistantPreviewMarkdownComponents}
>
{children}
</Streamdown>
)
}
function SupportAssistantPreviewImage({ src }: JSX.IntrinsicElements['img']) {
return <span className="font-mono text-foreground-lighter">[Image: {src?.toString()}]</span>
}
const supportAssistantPreviewMarkdownComponents: StreamdownProps['components'] = {
img: SupportAssistantPreviewImage,
}
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>
)
}
@@ -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
@@ -1466,6 +1466,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.
*
@@ -3378,6 +3393,7 @@ export type TelemetryEvent =
| SupabaseUiCommandCopyButtonClickedEvent
| SupportTicketSubmittedEvent
| AiAssistantInSupportFormClickedEvent
| SupportAssistantFollowUpCardClickedEvent
| OrganizationMfaEnforcementUpdatedEvent
| ForeignDataWrapperCreatedEvent
| StorageBucketCreatedEvent