mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 --> [](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:
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'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,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>
|
||||
)
|
||||
}
|
||||
+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}
|
||||
/>
|
||||
),
|
||||
{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user