mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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 [](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:
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'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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function unescapeSupportTagValue(value: string) {
|
||||
return value.replace(/</g, '<').replace(/>/g, '>').replace(/&/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>
|
||||
)
|
||||
}
|
||||
+7
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user