mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
Fixes FE-4200, FE-4206. ## What is the current behavior? Submitting a support ticket from an org-level page (with no project in the URL) shows a "While you wait" AI assistant card. However, the assistant is built around project-scoped context from the URL, so making it work here required adding project-context fallbacks across several features. Two previous PRs addressed individual issues, but testing continued to surface the same underlying problem in other areas, including chat persistence, message rating, table browsing, and SQL editor actions. - **#49244** - **#49430** Rather than keep adding fallbacks, this PR removes the underlying context mismatch. ## What is the new behavior? When a support ticket is submitted from an org-level page, the "While you wait" card now links to the relevant project instead of trying to run the AI Assistant without real project context. The link opens the project with the AI Assistant sidebar and hands off the support ticket. The chat is created there, so project-scoped features like schema browsing, SQL actions, message rating, and chat persistence work natively without special-casing. If there’s no relevant project, the card isn’t shown. The ticket form also restores the "No specific project" option for cases where the auto-selected project isn't relevant. ## Additional context Also fixes ?sidebar= deep links not opening the sidebar after client-side navigation. LayoutSidebarProvider now reacts to URL param changes instead of only checking on initial load. ## How to test 1. Submit a project-related support ticket from an org-level page. 2. Confirm the "While you wait" card shows "Open Assistant in project". 3. Click it and confirm the correct project opens with the AI Assistant sidebar and support chat active. 4. Verify project-scoped features work, such as schema questions and Edit query / Run. 5. Refresh and confirm the chat persists. 6. Select "No specific project" and confirm no assistant card is shown. 7. Submit a ticket from a project support page and confirm the existing inline assistant behavior is unchanged. 8. Verify a project ?sidebar=ai-assistant deep link still opens the sidebar normally. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support handoff links when a submitted ticket belongs to another project. * Handoff links securely preserve support request details without exposing them in the URL. * Opening a valid handoff link creates and selects a support chat with the submitted request context. * **Bug Fixes** * Improved sidebar behavior when URL state changes. * Project selector validation messages now remain visible. * Invalid, expired, or mismatched handoffs now fall back to a new chat and display an error message. * Handoff details are securely handled only once and cleared after use. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
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,
|
|
chats,
|
|
mockNewChat,
|
|
mockOpenSidebar,
|
|
mockSelectChat,
|
|
mockSyncSupportChatToFront,
|
|
mockTrack,
|
|
} = vi.hoisted(() => ({
|
|
chatInstances: {} as Record<string, MockChat>,
|
|
chats: {} as Record<string, { messages: unknown[]; supportMetadata?: unknown }>,
|
|
mockNewChat: vi.fn(),
|
|
mockOpenSidebar: vi.fn(),
|
|
mockSelectChat: vi.fn(),
|
|
mockSyncSupportChatToFront: 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,
|
|
}),
|
|
useAiAssistantState: () => ({
|
|
chats,
|
|
selectChat: mockSelectChat,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/state/ai-chat-front-sync', () => ({
|
|
syncSupportChatToFront: mockSyncSupportChatToFront,
|
|
}))
|
|
|
|
vi.mock('@/state/sidebar-manager-state', () => ({
|
|
useSidebarManagerSnapshot: () => ({
|
|
openSidebar: mockOpenSidebar,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/telemetry/track', () => ({
|
|
useTrack: () => mockTrack,
|
|
}))
|
|
|
|
let mockCurrentProjectRef: string | undefined = 'project-1'
|
|
|
|
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
|
useSelectedProjectQuery: () => ({
|
|
data: mockCurrentProjectRef ? { ref: mockCurrentProjectRef } : undefined,
|
|
}),
|
|
}))
|
|
|
|
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,
|
|
threadRef: 'thread-ref-1',
|
|
frontConversationId: 'front-conversation-1',
|
|
}
|
|
|
|
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])
|
|
Object.keys(chats).forEach((key) => delete chats[key])
|
|
mockNewChat.mockReset()
|
|
mockOpenSidebar.mockReset()
|
|
mockSelectChat.mockReset()
|
|
mockSyncSupportChatToFront.mockReset()
|
|
mockTrack.mockReset()
|
|
nextChatMessages = []
|
|
emitChatMessagesChange = undefined
|
|
mockCurrentProjectRef = 'project-1'
|
|
sessionStorage.clear()
|
|
|
|
mockNewChat.mockImplementation(() => {
|
|
chatInstances['chat-1'] = createMockChat(nextChatMessages)
|
|
chats['chat-1'] = { messages: 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('tags the chat as a support chat and syncs it to Front on first open', 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(chats['chat-1'].supportMetadata).toMatchObject({
|
|
isSupportChat: true,
|
|
lifecycleStatus: 'bot_active',
|
|
subject: 'API requests fail',
|
|
category: SupportCategories.PROBLEM,
|
|
severity: 'Normal',
|
|
organizationSlug: 'org-1',
|
|
projectRef: 'project-1',
|
|
allowSupportAccess: true,
|
|
threadRef: 'thread-ref-1',
|
|
frontConversationId: 'front-conversation-1',
|
|
lastSyncedMessageCount: 0,
|
|
isSyncing: false,
|
|
isLifecycleSyncing: false,
|
|
})
|
|
|
|
// The initial flush is behind a dynamic import, so it lands asynchronously
|
|
await waitFor(() => {
|
|
expect(mockSyncSupportChatToFront).toHaveBeenCalledWith(
|
|
'chat-1',
|
|
expect.objectContaining({ chats })
|
|
)
|
|
})
|
|
|
|
// A second open must not re-tag the chat or trigger another initial flush
|
|
const taggedMetadata = chats['chat-1'].supportMetadata
|
|
await user.click(button)
|
|
|
|
await waitFor(() => {
|
|
expect(mockSelectChat).toHaveBeenCalledTimes(2)
|
|
})
|
|
expect(chats['chat-1'].supportMetadata).toBe(taggedMetadata)
|
|
expect(mockSyncSupportChatToFront).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
describe('when not already on the ticket project page', () => {
|
|
beforeEach(() => {
|
|
mockCurrentProjectRef = undefined
|
|
})
|
|
|
|
it('renders a handoff link with an opaque token, keeping the ticket content out of the URL', async () => {
|
|
render(<SupportAssistantSuccessCard request={supportRequest} />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'While you wait' })).toBeInTheDocument()
|
|
const link = screen.getByRole('link', { name: /open assistant in project/i })
|
|
expect(link).toHaveAttribute('href', expect.stringContaining('/project/project-1?'))
|
|
|
|
const href = link.getAttribute('href') ?? ''
|
|
const token = new URL(href, 'https://example.com').searchParams.get('assistantHandoff')
|
|
expect(token).toBeTruthy()
|
|
// The message (and the rest of the ticket) must never appear in the URL itself.
|
|
expect(href).not.toContain(encodeURIComponent(supportRequest.message))
|
|
|
|
// The actual ticket content is stashed in sessionStorage instead, keyed by that token.
|
|
expect(sessionStorage.getItem(`assistant-handoff:${token}`)).toBe(
|
|
JSON.stringify(supportRequest)
|
|
)
|
|
expect(mockNewChat).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('tracks the click and does not touch the sidebar manager directly', async () => {
|
|
const user = userEvent.setup()
|
|
render(<SupportAssistantSuccessCard request={supportRequest} />)
|
|
|
|
const link = await screen.findByRole('link', { name: /open assistant in project/i })
|
|
await user.click(link)
|
|
|
|
expect(mockTrack).toHaveBeenCalledWith(
|
|
'support_assistant_follow_up_card_clicked',
|
|
{ ticketCategory: SupportCategories.PROBLEM },
|
|
{ project: 'project-1', organization: 'org-1' }
|
|
)
|
|
expect(mockOpenSidebar).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|